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 @@ ![biometric](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/biometric/banner.png) -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, +}