diff --git a/.changes/biometric-cipher-android.md b/.changes/biometric-cipher-android.md
new file mode 100644
index 000000000..ca78a7219
--- /dev/null
+++ b/.changes/biometric-cipher-android.md
@@ -0,0 +1,6 @@
+---
+"biometric": minor:feat
+"biometric-js": minor:feat
+---
+
+Add `biometricCipher` to encrypt and decrypt data on Android with a key protected by strong biometric authentication.
diff --git a/examples/api/src-tauri/capabilities/mobile.json b/examples/api/src-tauri/capabilities/mobile.json
index da77f5e53..8bc0ca97e 100644
--- a/examples/api/src-tauri/capabilities/mobile.json
+++ b/examples/api/src-tauri/capabilities/mobile.json
@@ -8,6 +8,8 @@
"nfc:allow-write",
"nfc:allow-scan",
"biometric:allow-authenticate",
+ "biometric:allow-status",
+ "biometric:allow-biometric-cipher",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"barcode-scanner:allow-request-permissions",
diff --git a/examples/api/src/views/Biometric.svelte b/examples/api/src/views/Biometric.svelte
index 3f287b0d1..ca040124d 100644
--- a/examples/api/src/views/Biometric.svelte
+++ b/examples/api/src/views/Biometric.svelte
@@ -1,9 +1,14 @@
-
+
-
+
+
+{#if isAndroid}
+
+
Biometric cipher
+
+
+
+
+
+{/if}
diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md
index 745111307..abc98972e 100644
--- a/packages/api-e2e/README.md
+++ b/packages/api-e2e/README.md
@@ -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
diff --git a/packages/api-e2e/test/specs/biometric.spec.ts b/packages/api-e2e/test/specs/biometric.spec.ts
index 6b6b559be..f55c13797 100644
--- a/packages/api-e2e/test/specs/biometric.spec.ts
+++ b/packages/api-e2e/test/specs/biometric.spec.ts
@@ -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 {
+ 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 {
+ 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 {
+ await browser.activateApp(appId)
+ await switchToWebview()
+}
+
+/** Switches back to the app's webview once it is attachable again. */
+async function switchToWebview(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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' })
+ })
+ })
})
diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts
index 42673fa5d..bfa8a7a0a 100644
--- a/packages/api-e2e/test/specs/plugins.spec.ts
+++ b/packages/api-e2e/test/specs/plugins.spec.ts
@@ -23,7 +23,7 @@ type Surface = { [P in keyof T]: (keyof T[P])[] }
/** Plugins the example registers on every platform. */
const commonSurface: Surface = {
- biometric: ['BiometryType', 'checkStatus', 'authenticate'],
+ biometric: ['BiometryType', 'checkStatus', 'authenticate', 'biometricCipher'],
clipboardManager: [
'writeText',
'readText',
diff --git a/plugins/biometric/README.md b/plugins/biometric/README.md
index 381950abc..342772da0 100644
--- a/plugins/biometric/README.md
+++ b/plugins/biometric/README.md
@@ -1,14 +1,14 @@

-Prompt the user for biometric authentication on Android, iOS and macOS.
+Prompt the user for biometric authentication on Android, iOS and macOS. On Android, it also allows encrypting and decrypting data with keys protected by biometric authentication.
-| Platform | Supported |
-| -------- | --------- |
-| Linux | x |
-| Windows | x |
-| macOS | ✓ |
-| Android | ✓ |
-| iOS | ✓ |
+| Platform | Supported |
+| -------- | ----------------------------- |
+| Linux | x |
+| Windows | x |
+| macOS | ✓ |
+| Android | ✓ |
+| iOS | ✓ (no biometric cryptography) |
## Install
@@ -61,8 +61,45 @@ fn main() {
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript
-import { authenticate } from '@tauri-apps/plugin-biometric'
-await authenticate('Open your wallet')
+import {
+ checkStatus,
+ authenticate,
+ biometricCipher
+} from '@tauri-apps/plugin-biometric'
+
+const status = await checkStatus()
+if (status.isAvailable) {
+ await authenticate('Open your wallet')
+}
+
+// ... or using biometric-protected cryptography:
+if (status.isAvailable) {
+ const encryptOptions = {
+ dataToEncrypt: getData()
+ }
+ const encrypted = await biometricCipher(
+ 'Login without typing password',
+ encryptOptions
+ )
+
+ const decryptOptions = {
+ dataToDecrypt: encrypted.data
+ }
+ const originalData = await biometricCipher(
+ 'Login without typing password',
+ decryptOptions
+ )
+}
+```
+
+## Permissions
+
+The default permission set (`biometric:default`) only allows `status` and `authenticate`. To use `biometricCipher`, add the `biometric:allow-biometric-cipher` permission to your capability:
+
+```json
+{
+ "permissions": ["biometric:default", "biometric:allow-biometric-cipher"]
+}
```
## Contributing
diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt
index 83cc1241a..ecb8e80e7 100644
--- a/plugins/biometric/android/src/main/java/BiometricActivity.kt
+++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt
@@ -16,23 +16,131 @@ import android.os.Handler
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricPrompt
import java.util.concurrent.Executor
+import android.util.Base64
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+//import javax.crypto.spec.IvParameterSpec
+import java.nio.charset.Charset
+import app.tauri.biometric.CipherType
class BiometricActivity : AppCompatActivity() {
+ private var ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
+ private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
+ private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
+ private val KEYSTORE_NAME = "AndroidKeyStore"
+ private val KEY_ALIAS = "tauri-plugin-biometric-key"
+
@SuppressLint("WrongConstant")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.auth_activity)
- val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- this.mainExecutor
- } else {
- Executor { command: Runnable? ->
- Handler(this.mainLooper).post(
- command!!
- )
- }
+ cipherOperation = intent.hasExtra(BiometricPlugin.ENCRYPT_DECRYPT_OPERATION)
+
+ // null when the activity already finished with an error
+ val promptInfo = createPromptInfo() ?: return
+ val prompt = createBiometricPrompt()
+
+ if (!cipherOperation) {
+ prompt.authenticate(promptInfo)
+ return
}
+ intent.getStringExtra(BiometricPlugin.ENCRYPT_DECRYPT_DATA)?.let {
+ encryptDecryptData = it
+ }
+
+ try {
+ val type = CipherType.values()[intent.getIntExtra(BiometricPlugin.CIPHER_OPERATION_TYPE, CipherType.ENCRYPT.ordinal)]
+ cipherType = type
+ } catch (e: ArrayIndexOutOfBoundsException) {
+ finishActivity(
+ BiometryResultType.ERROR,
+ 0,
+ "Couldn't identify the cipher operation type (encrypt/decrypt)!"
+ )
+ return
+ }
+
+ // Fails on malformed encrypted data or a key that is no longer valid
+ // (e.g. invalidated by a new biometric enrollment).
+ val cipher = try {
+ getCipher()
+ } catch (e: Exception) {
+ finishActivity(
+ BiometryResultType.ERROR,
+ 0,
+ "Failed to initialize the cipher: ${e.message ?: e.javaClass.simpleName}"
+ )
+ return
+ }
+ prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
+ }
+
+ @JvmOverloads
+ fun finishActivity(
+ resultType: BiometryResultType = BiometryResultType.SUCCESS,
+ errorCode: Int = 0,
+ errorMessage: String? = ""
+ ) {
+ val intent = Intent()
+ val prefix = BiometricPlugin.RESULT_EXTRA_PREFIX
+ intent
+ .putExtra(prefix + BiometricPlugin.RESULT_TYPE, resultType.toString())
+ .putExtra(prefix + BiometricPlugin.RESULT_ERROR_CODE, errorCode)
+ .putExtra(
+ prefix + BiometricPlugin.RESULT_ERROR_MESSAGE,
+ errorMessage
+ )
+ .putExtra(
+ prefix + BiometricPlugin.ENCRYPT_DECRYPT_OPERATION,
+ cipherOperation
+ )
+
+ if (resultType == BiometryResultType.SUCCESS && cipherOperation) {
+ // Decrypting data that was tampered with or encrypted with another key fails here.
+ val processedData = try {
+ processCipherData()
+ } catch (e: Exception) {
+ finishActivity(
+ BiometryResultType.ERROR,
+ 0,
+ "Failed to ${if (cipherType == CipherType.ENCRYPT) "encrypt" else "decrypt"} the data: ${e.message ?: e.javaClass.simpleName}"
+ )
+ return
+ }
+ intent.putExtra(
+ prefix + BiometricPlugin.RESULT_ENCRYPT_DECRYPT_DATA,
+ processedData
+ )
+ }
+ setResult(Activity.RESULT_OK, intent)
+ finish()
+ }
+
+ /**
+ * Encrypts or decrypts [encryptDecryptData] with the cipher unlocked by the authentication.
+ * Encrypted data is returned as Base64 `data;iv`, decrypted data as a UTF-8 string.
+ */
+ private fun processCipherData(): String {
+ val cryptoObject = requireNotNull(authenticationResult?.cryptoObject)
+ val cipher = requireNotNull(cryptoObject.cipher)
+ return if (cipherType == CipherType.ENCRYPT) {
+ encodeEncryptedData(cipher, cipher.doFinal(encryptDecryptData.toByteArray()))
+ } else {
+ val (_, encryptedData) = decodeEncryptedData(encryptDecryptData)
+ String(cipher.doFinal(encryptedData), Charset.forName("UTF-8"))
+ }
+ }
+
+ private fun createPromptInfo(): BiometricPrompt.PromptInfo? {
val builder = BiometricPrompt.PromptInfo.Builder()
val intent = intent
var title = intent.getStringExtra(BiometricPlugin.TITLE)
@@ -54,7 +162,7 @@ class BiometricActivity : AppCompatActivity() {
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
getString(R.string.tauri_biometric_no_device_credential)
)
- return
+ return null
}
if (title.isNullOrEmpty()) {
@@ -63,7 +171,11 @@ class BiometricActivity : AppCompatActivity() {
builder.setTitle(title).setSubtitle(subtitle).setDescription(description)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- var authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK
+ var authenticators = if (cipherOperation) {
+ BiometricManager.Authenticators.BIOMETRIC_STRONG
+ } else {
+ BiometricManager.Authenticators.BIOMETRIC_WEAK
+ }
if (allowDeviceCredential) {
authenticators = authenticators or BiometricManager.Authenticators.DEVICE_CREDENTIAL
}
@@ -86,50 +198,119 @@ class BiometricActivity : AppCompatActivity() {
builder.setConfirmationRequired(
intent.getBooleanExtra(BiometricPlugin.CONFIRMATION_REQUIRED, true)
)
- val promptInfo = builder.build()
- val prompt = BiometricPrompt(
- this,
- executor,
- object : BiometricPrompt.AuthenticationCallback() {
- override fun onAuthenticationError(
- errorCode: Int,
- errorMessage: CharSequence
- ) {
- super.onAuthenticationError(errorCode, errorMessage)
- finishActivity(
- BiometryResultType.ERROR,
- errorCode,
- errorMessage.toString()
- )
- }
-
- override fun onAuthenticationSucceeded(
- result: BiometricPrompt.AuthenticationResult
- ) {
- super.onAuthenticationSucceeded(result)
- finishActivity()
- }
- }
- )
- prompt.authenticate(promptInfo)
+ return builder.build()
}
- @JvmOverloads
- fun finishActivity(
- resultType: BiometryResultType = BiometryResultType.SUCCESS,
- errorCode: Int = 0,
- errorMessage: String? = ""
- ) {
- val intent = Intent()
- val prefix = BiometricPlugin.RESULT_EXTRA_PREFIX
- intent
- .putExtra(prefix + BiometricPlugin.RESULT_TYPE, resultType.toString())
- .putExtra(prefix + BiometricPlugin.RESULT_ERROR_CODE, errorCode)
- .putExtra(
- prefix + BiometricPlugin.RESULT_ERROR_MESSAGE,
- errorMessage
+ private fun createBiometricPrompt(): BiometricPrompt {
+ val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ this.mainExecutor
+ } else {
+ Executor { command: Runnable? ->
+ Handler(this.mainLooper).post(
+ command!!
+ )
+ }
+ }
+
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(
+ errorCode: Int,
+ errorMessage: CharSequence
+ ) {
+ super.onAuthenticationError(errorCode, errorMessage)
+ finishActivity(
+ BiometryResultType.ERROR,
+ errorCode,
+ errorMessage.toString()
+ )
+ }
+
+ override fun onAuthenticationSucceeded(
+ result: BiometricPrompt.AuthenticationResult
+ ) {
+ super.onAuthenticationSucceeded(result)
+ authenticationResult = result
+ finishActivity()
+ }
+ }
+
+ return BiometricPrompt(this, executor, callback)
+ }
+
+ /**
+ * Get the key if exists, or create a new one if not
+ */
+ private fun getEncryptionKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(KEYSTORE_NAME)
+ keyStore.load(null)
+ keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
+
+ // from here ahead, we will move only if there is not a key with the provided alias
+ val keyGenerator = KeyGenerator.getInstance(ENCRYPTION_ALGORITHM, KEYSTORE_NAME)
+
+ val builder = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
- setResult(Activity.RESULT_OK, intent)
- finish()
+ .setBlockModes(ENCRYPTION_BLOCK_MODE)
+ .setEncryptionPaddings(ENCRYPTION_PADDING)
+ .setKeySize(256)
+ .setRandomizedEncryptionRequired(true)
+ .setUserAuthenticationRequired(true) // Forces to use the biometric authentication to create/retrieve the key
+
+ keyGenerator.init(builder.build())
+ return keyGenerator.generateKey()
+ }
+
+ private fun deleteEncryptionKey() {
+ val keyStore = KeyStore.getInstance(KEYSTORE_NAME)
+ keyStore.load(null)
+ keyStore.deleteEntry(KEY_ALIAS)
+ }
+
+ private fun getCipher(): Cipher {
+ val biometricKey = getEncryptionKey()
+
+ val cipher = Cipher.getInstance("$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING")
+
+ if (cipherType == CipherType.ENCRYPT) {
+ try {
+ cipher.init(Cipher.ENCRYPT_MODE, biometricKey)
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ // The key was invalidated by a biometric enrollment change or the lock screen
+ // being removed. Whatever it encrypted can no longer be decrypted, so replace it.
+ deleteEncryptionKey()
+ cipher.init(Cipher.ENCRYPT_MODE, getEncryptionKey())
+ }
+ } else {
+ // Decodes the Base64 string to get the encrypted data and IV
+ val (iv, _) = decodeEncryptedData(encryptDecryptData)
+ cipher.init(Cipher.DECRYPT_MODE, biometricKey, GCMParameterSpec(128, iv))
+ }
+ return cipher
+ }
+
+ private fun encodeEncryptedData(cipher: Cipher, rawEncryptedData: ByteArray): String {
+ val encodedData = Base64.encodeToString(rawEncryptedData, Base64.NO_WRAP)
+ val encodedIv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
+
+ val encodedString = encodedData + ";" + encodedIv
+ return encodedString
+ }
+
+ private fun decodeEncryptedData(encryptedDataEncoded: String): List {
+ val (data, iv) = encryptedDataEncoded.split(";")
+ val decodedData = Base64.decode(data, Base64.NO_WRAP)
+ val decodedIv = Base64.decode(iv, Base64.NO_WRAP)
+
+ val ret = listOf(decodedIv, decodedData)
+ return ret
+ }
+
+ companion object {
+ var cipherOperation = false
+ var encryptDecryptData: String = ""
+ var authenticationResult: BiometricPrompt.AuthenticationResult? = null
+ var cipherType: CipherType = CipherType.ENCRYPT
}
}
\ No newline at end of file
diff --git a/plugins/biometric/android/src/main/java/BiometricPlugin.kt b/plugins/biometric/android/src/main/java/BiometricPlugin.kt
index b9b7ab05d..9391b884e 100644
--- a/plugins/biometric/android/src/main/java/BiometricPlugin.kt
+++ b/plugins/biometric/android/src/main/java/BiometricPlugin.kt
@@ -28,6 +28,10 @@ enum class BiometryResultType {
SUCCESS, FAILURE, ERROR
}
+enum class CipherType {
+ ENCRYPT, DECRYPT
+}
+
private const val MAX_ATTEMPTS = "maxAttemps"
private const val BIOMETRIC_FAILURE = "authenticationFailed"
private const val INVALID_CONTEXT_ERROR = "invalidContext"
@@ -41,6 +45,8 @@ class AuthOptions {
var cancelTitle: String? = null
var confirmationRequired: Boolean? = null
var maxAttemps: Int = 3
+ var dataToEncrypt: String? = null
+ var dataToDecrypt: String? = null
}
@TauriPlugin
@@ -58,32 +64,36 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
const val RESULT_ERROR_MESSAGE = "errorMessage"
const val DEVICE_CREDENTIAL = "allowDeviceCredential"
const val CONFIRMATION_REQUIRED = "confirmationRequired"
+ const val ENCRYPT_DECRYPT_OPERATION = "cipherOperation"
+ const val ENCRYPT_DECRYPT_DATA = "encryptDecryptData"
+ const val CIPHER_OPERATION_TYPE = "cipherOperationType"
+ const val RESULT_ENCRYPT_DECRYPT_DATA = "resultEncryptDecryptData"
// Maps biometry error numbers to string error codes
private var biometryErrorCodeMap: MutableMap = HashMap()
private var biometryNameMap: MutableMap = EnumMap(BiometryType::class.java)
- init {
- biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
- biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
- biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] = "systemCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_HW_NOT_PRESENT] = "biometryNotAvailable"
- biometryErrorCodeMap[BiometricPrompt.ERROR_HW_UNAVAILABLE] = "biometryNotAvailable"
- biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT] = "biometryLockout"
- biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout"
- biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_NO_BIOMETRICS] = "biometryNotEnrolled"
- biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "passcodeNotSet"
- biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_USER_CANCELED] = "userCancel"
- biometryErrorCodeMap[BiometricPrompt.ERROR_VENDOR] = "systemCancel"
+ init {
+ biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
+ biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = ""
+ biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] = "systemCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_HW_NOT_PRESENT] = "biometryNotAvailable"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_HW_UNAVAILABLE] = "biometryNotAvailable"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT] = "biometryLockout"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_NO_BIOMETRICS] = "biometryNotEnrolled"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "passcodeNotSet"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_USER_CANCELED] = "userCancel"
+ biometryErrorCodeMap[BiometricPrompt.ERROR_VENDOR] = "systemCancel"
- biometryNameMap[BiometryType.NONE] = "No Authentication"
- biometryNameMap[BiometryType.FINGERPRINT] = "Fingerprint Authentication"
- biometryNameMap[BiometryType.FACE] = "Face Authentication"
- biometryNameMap[BiometryType.IRIS] = "Iris Authentication"
+ biometryNameMap[BiometryType.NONE] = "No Authentication"
+ biometryNameMap[BiometryType.FINGERPRINT] = "Fingerprint Authentication"
+ biometryNameMap[BiometryType.FACE] = "Face Authentication"
+ biometryNameMap[BiometryType.IRIS] = "Iris Authentication"
}
}
@@ -157,18 +167,15 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
}
/**
- * Prompt the user for biometric authentication.
+ * sets up the options for BiometricPrompt.
*/
- @Command
- fun authenticate(invoke: Invoke) {
+ private fun configBiometricPrompt(args: AuthOptions): Intent {
// The result of an intent is supposed to have the package name as a prefix
RESULT_EXTRA_PREFIX = activity.packageName + "."
val intent = Intent(
activity,
BiometricActivity::class.java
)
-
- val args = invoke.parseArgs(AuthOptions::class.java)
// Pass the options to the activity
intent.putExtra(
@@ -186,9 +193,52 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
val maxAttemptsConfig = args.maxAttemps
val maxAttempts = max(maxAttemptsConfig, 1)
intent.putExtra(MAX_ATTEMPTS, maxAttempts)
+
+ return intent
+ }
+
+ /**
+ * Prompt the user for biometric authentication.
+ */
+ @Command
+ fun authenticate(invoke: Invoke) {
+ val args: AuthOptions = invoke.parseArgs(AuthOptions::class.java)
+
+ val intent = configBiometricPrompt(args)
startActivityForResult(invoke, intent, "authenticateResult")
}
+
+ /**
+ * Prompt the user for biometric authentication to encrypt/decrypt some provided data]
+ */
+ @Command
+ fun biometricCipher(invoke: Invoke) {
+ val args: AuthOptions = invoke.parseArgs(AuthOptions::class.java)
+
+ if (args.dataToEncrypt == null && args.dataToDecrypt == null) {
+ invoke.reject("Either dataToEncrypt or dataToDecrypt must be provided")
+ return
+ }
+
+ val intent = configBiometricPrompt(args)
+ var operationType: CipherType
+
+ val data = if (args.dataToEncrypt != null) {
+ operationType = CipherType.ENCRYPT
+ args.dataToEncrypt
+ } else {
+ operationType = CipherType.DECRYPT
+ args.dataToDecrypt
+ }
+ intent.putExtra(ENCRYPT_DECRYPT_DATA, data)
+ intent.putExtra(ENCRYPT_DECRYPT_OPERATION, true)
+ intent.putExtra(CIPHER_OPERATION_TYPE, operationType.ordinal)
+
+ startActivityForResult(invoke, intent, "authenticateResult")
+ }
+
+
@ActivityCallback
private fun authenticateResult(invoke: Invoke, result: ActivityResult) {
val resultCode = result.resultCode
@@ -231,8 +281,29 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
var errorMessage = data.getStringExtra(
RESULT_EXTRA_PREFIX + RESULT_ERROR_MESSAGE
)
+
+ val cipherOperation = data.getBooleanExtra(RESULT_EXTRA_PREFIX + ENCRYPT_DECRYPT_OPERATION, false)
+ var resolvedData = JSObject()
+
+ if (cipherOperation) {
+ val processedData = data.getStringExtra(
+ RESULT_EXTRA_PREFIX + RESULT_ENCRYPT_DECRYPT_DATA
+ )
+
+ resolvedData.put(
+ "data",
+ processedData
+ )
+ }
+
when (resultType) {
- BiometryResultType.SUCCESS -> invoke.resolve()
+ BiometryResultType.SUCCESS -> {
+ if (cipherOperation) {
+ invoke.resolve(resolvedData)
+ } else {
+ invoke.resolve()
+ }
+ }
BiometryResultType.FAILURE -> // Biometry was successfully presented but was not recognized
invoke.reject(errorMessage, BIOMETRIC_FAILURE)
diff --git a/plugins/biometric/api-iife.js b/plugins/biometric/api-iife.js
index 4e1e9c120..bfae4fd2f 100644
--- a/plugins/biometric/api-iife.js
+++ b/plugins/biometric/api-iife.js
@@ -1 +1 @@
-if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(e,i){"use strict";var t;return e.BiometryType=void 0,(t=e.BiometryType||(e.BiometryType={}))[t.None=0]="None",t[t.TouchID=1]="TouchID",t[t.FaceID=2]="FaceID",t[t.Iris=3]="Iris",e.authenticate=async function(e,t){await i.invoke("plugin:biometric|authenticate",{reason:e,...t})},e.checkStatus=async function(){return await i.invoke("plugin:biometric|status")},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})}
+if("__TAURI__"in window){var __TAURI_PLUGIN_BIOMETRIC__=function(i,e){"use strict";var t;return i.BiometryType=void 0,(t=i.BiometryType||(i.BiometryType={}))[t.None=0]="None",t[t.TouchID=1]="TouchID",t[t.FaceID=2]="FaceID",t[t.Iris=3]="Iris",i.authenticate=async function(i,t){await e.invoke("plugin:biometric|authenticate",{reason:i,...t})},i.biometricCipher=async function(i,t){return await e.invoke("plugin:biometric|biometric_cipher",{reason:i,...t})},i.checkStatus=async function(){return await e.invoke("plugin:biometric|status")},i}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"biometric",{value:__TAURI_PLUGIN_BIOMETRIC__})}
diff --git a/plugins/biometric/build.rs b/plugins/biometric/build.rs
index 18e85e01f..e313365c4 100644
--- a/plugins/biometric/build.rs
+++ b/plugins/biometric/build.rs
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
-const COMMANDS: &[&str] = &["authenticate", "status"];
+const COMMANDS: &[&str] = &["authenticate", "status", "biometric_cipher"];
fn main() {
let result = tauri_plugin::Builder::new(COMMANDS)
diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts
index cddd613c5..10579beb2 100644
--- a/plugins/biometric/guest-js/index.ts
+++ b/plugins/biometric/guest-js/index.ts
@@ -58,6 +58,10 @@ export interface AuthOptions {
allowDeviceCredential?: boolean
/** Label for the cancel button. Available on Android, iOS and macOS. */
cancelTitle?: string
+ /** The plain data that must be encrypted after successful biometric authentication. Used by {@linkcode biometricCipher}. **Android only.** */
+ dataToEncrypt?: string
+ /** The encrypted data that must be decrypted after successful biometric authentication. Used by {@linkcode biometricCipher}. **Android only.** */
+ dataToDecrypt?: string
// iOS and macOS options
/** Text displayed on the fallback button if biometric authentication fails. **iOS and macOS only.** */
@@ -115,3 +119,41 @@ export async function authenticate(
...options
})
}
+
+/**
+ * Encrypts/Decrypts some payload using biometric authentication. It will Prompt the user to authenticate
+ * using the system interface (touchID, faceID or Android Iris).
+ * Rejects if the authentication fails.
+ *
+ * Warning: consider that if the data is encrypted and the user changes the biometric settings, the key needed
+ * to decrypt the data will be lost. So, it's recommended to use this only to data that the user can get back
+ * by other means (i.e. storing their password in a secure way so they can login passwordless with biometric authentication).
+ *
+ * ```javascript
+ * import { biometricCipher } from "@tauri-apps/plugin-biometric";
+ *
+ * // how to encrypt some data
+ * const options = {
+ * dataToEncrypt: "...", // if not empty, will encrypt this data
+ * };
+ * const encryptedData = await biometricCipher('Open your wallet', options);
+ *
+ * // how to decrypt the encrypted data
+ * const options = {
+ * dataToDecrypt: encryptedData // if not empty, will decrypt the data (that was previously encrypted by this method)
+ * };
+ * const decryptedData = await biometricCipher('Open your wallet', options);
+ * ```
+ * @param reason
+ * @param options
+ * @returns
+ */
+export async function biometricCipher(
+ reason: string,
+ options?: AuthOptions
+): Promise<{ data: string }> {
+ return await invoke<{ data: string }>('plugin:biometric|biometric_cipher', {
+ reason,
+ ...options
+ })
+}
diff --git a/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml b/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml
new file mode 100644
index 000000000..224972558
--- /dev/null
+++ b/plugins/biometric/permissions/autogenerated/commands/biometric_cipher.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-biometric-cipher"
+description = "Enables the biometric_cipher command without any pre-configured scope."
+commands.allow = ["biometric_cipher"]
+
+[[permission]]
+identifier = "deny-biometric-cipher"
+description = "Denies the biometric_cipher command without any pre-configured scope."
+commands.deny = ["biometric_cipher"]
diff --git a/plugins/biometric/permissions/autogenerated/reference.md b/plugins/biometric/permissions/autogenerated/reference.md
index e1b29bb02..12dd792f8 100644
--- a/plugins/biometric/permissions/autogenerated/reference.md
+++ b/plugins/biometric/permissions/autogenerated/reference.md
@@ -5,7 +5,11 @@ biometric features are by default exposed.
#### Granted Permissions
-It allows acccess to all biometric commands.
+It allows checking the biometric status and
+prompting for authentication. Encrypting and
+decrypting data with `biometric_cipher` is not
+included and must be enabled explicitly with
+`biometric:allow-biometric-cipher`.
#### This default permission set includes the following:
@@ -50,6 +54,32 @@ Denies the authenticate command without any pre-configured scope.
+`biometric:allow-biometric-cipher`
+
+
+
+
+Enables the biometric_cipher command without any pre-configured scope.
+
+
+
+
+
+
+
+`biometric:deny-biometric-cipher`
+
+
+
+
+Denies the biometric_cipher command without any pre-configured scope.
+
+
+
+
+
+
+
`biometric:allow-status`
diff --git a/plugins/biometric/permissions/default.toml b/plugins/biometric/permissions/default.toml
index 651990efe..0a5b49d21 100644
--- a/plugins/biometric/permissions/default.toml
+++ b/plugins/biometric/permissions/default.toml
@@ -6,7 +6,11 @@ biometric features are by default exposed.
#### Granted Permissions
-It allows acccess to all biometric commands.
+It allows checking the biometric status and
+prompting for authentication. Encrypting and
+decrypting data with `biometric_cipher` is not
+included and must be enabled explicitly with
+`biometric:allow-biometric-cipher`.
"""
diff --git a/plugins/biometric/permissions/schemas/schema.json b/plugins/biometric/permissions/schemas/schema.json
index 416759b53..731ae0365 100644
--- a/plugins/biometric/permissions/schemas/schema.json
+++ b/plugins/biometric/permissions/schemas/schema.json
@@ -306,6 +306,18 @@
"const": "deny-authenticate",
"markdownDescription": "Denies the authenticate command without any pre-configured scope."
},
+ {
+ "description": "Enables the biometric_cipher command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-biometric-cipher",
+ "markdownDescription": "Enables the biometric_cipher command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the biometric_cipher command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-biometric-cipher",
+ "markdownDescription": "Denies the biometric_cipher command without any pre-configured scope."
+ },
{
"description": "Enables the status command without any pre-configured scope.",
"type": "string",
@@ -319,10 +331,10 @@
"markdownDescription": "Denies the status command without any pre-configured scope."
},
{
- "description": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows acccess to all biometric commands.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`",
+ "description": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows checking the biometric status and\nprompting for authentication. Encrypting and\ndecrypting data with `biometric_cipher` is not\nincluded and must be enabled explicitly with\n`biometric:allow-biometric-cipher`.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`",
"type": "string",
"const": "default",
- "markdownDescription": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows acccess to all biometric commands.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`"
+ "markdownDescription": "This permission set configures which\nbiometric features are by default exposed.\n\n#### Granted Permissions\n\nIt allows checking the biometric status and\nprompting for authentication. Encrypting and\ndecrypting data with `biometric_cipher` is not\nincluded and must be enabled explicitly with\n`biometric:allow-biometric-cipher`.\n\n\n#### This default permission set includes:\n\n- `allow-authenticate`\n- `allow-status`"
}
]
}
diff --git a/plugins/biometric/src/commands.rs b/plugins/biometric/src/commands.rs
index 56643ddb9..c56a9572a 100644
--- a/plugins/biometric/src/commands.rs
+++ b/plugins/biometric/src/commands.rs
@@ -33,6 +33,7 @@ pub(crate) async fn authenticate(
title,
subtitle,
confirmation_required,
+ ..Default::default()
};
// The prompt blocks until the user answers, so keep it off the async runtime workers.
tauri::async_runtime::spawn_blocking(move || app.biometric().authenticate(reason, options))
diff --git a/plugins/biometric/src/mobile.rs b/plugins/biometric/src/mobile.rs
index a6c849dc6..13ccb8f17 100644
--- a/plugins/biometric/src/mobile.rs
+++ b/plugins/biometric/src/mobile.rs
@@ -53,4 +53,17 @@ impl Biometric {
.run_mobile_plugin("authenticate", AuthenticatePayload { reason, options })
.map_err(Into::into)
}
+
+ /// Prompts the user for biometric authentication and, on success, encrypts
+ /// [`AuthOptions::data_to_encrypt`] or decrypts [`AuthOptions::data_to_decrypt`] using a key
+ /// protected by the device's strong biometrics. **Android only.**
+ pub fn biometric_cipher(
+ &self,
+ reason: String,
+ options: AuthOptions,
+ ) -> crate::Result {
+ self.0
+ .run_mobile_plugin("biometricCipher", AuthenticatePayload { reason, options })
+ .map_err(Into::into)
+ }
}
diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs
index a9b541f34..4b955eff7 100644
--- a/plugins/biometric/src/models.rs
+++ b/plugins/biometric/src/models.rs
@@ -12,6 +12,10 @@ pub struct AuthOptions {
pub allow_device_credential: bool,
/// Label for the Cancel button. This feature is available on Android, iOS and macOS.
pub cancel_title: Option,
+ /// The plain data that must be encrypted after successful biometric authentication. **Android only.**
+ pub data_to_encrypt: Option,
+ /// The encrypted data that must be decrypted after successful biometric authentication. **Android only.**
+ pub data_to_decrypt: Option,
/// Specifies the text displayed on the fallback button if biometric authentication fails. This feature is available on iOS and macOS only.
pub fallback_title: Option,
/// Title indicating the purpose of biometric verification. This feature is available Android only.
@@ -50,3 +54,10 @@ pub struct Status {
/// Only set when [`Self::is_available`] is `false`.
pub error_code: Option,
}
+
+/// The result of [`Biometric::biometric_cipher`](crate::Biometric::biometric_cipher).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CipherResult {
+ /// The encrypted or decrypted data.
+ pub data: String,
+}