app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 1d7b35ffa2d4423f87b1861e609ff168b9d1dcc0
parent 1b5aca4447239c625c6c73ae3ee783e4e37b2978
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 19:08:06 +0000

android: require auth before revealing recovery key

- add an async home-action poll path in app core so Android backup reveal can complete after system auth
- start Android device authentication before the backup action reveals the current nsec
- add Kotlin bridge support for biometric or device-credential verification and result polling
- keep the Android host build and emulator launch green with the new security flow

Diffstat:
Mcrates/android/src/lib.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/android/src/security.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/lib.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mnative/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts | 1+
Mnative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt | 7+++++++
Anative/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 498 insertions(+), 13 deletions(-)

diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -80,12 +80,13 @@ impl RadrootsAppBackend for AndroidBackend { fn home_action_states(&self) -> Vec<HomeActionState> { #[cfg(target_os = "android")] { + let recovery_key_export_pending = Self::recovery_key_export_pending(); return vec![ HomeActionState { kind: HomeActionKind::BackupRecoveryKey, label: "Back Up Recovery Key".to_owned(), - enabled: true, - pending: false, + enabled: !recovery_key_export_pending, + pending: recovery_key_export_pending, }, HomeActionState { kind: HomeActionKind::RemoveLocalKey, @@ -111,15 +112,17 @@ impl RadrootsAppBackend for AndroidBackend { fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> { #[cfg(target_os = "android")] { - let manager = Self::accounts_manager()?; return match action { HomeActionKind::BackupRecoveryKey => { - Self::export_selected_local_recovery_key(&manager) - .map(|nsec| HomeActionResult::RevealRecoveryKey { nsec }) + Self::begin_recovery_key_export().map(|()| HomeActionResult::None) + } + HomeActionKind::RemoveLocalKey => { + let manager = Self::accounts_manager()?; + Self::remove_selected_local_identity(&manager) + .map(HomeActionResult::IdentityState) } - HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager) - .map(HomeActionResult::IdentityState), HomeActionKind::ResetDevice => { + let manager = Self::accounts_manager()?; let accounts_path = storage::accounts_path()?; Self::reset_local_device_state(&manager, accounts_path.as_path()) .map(HomeActionResult::IdentityState) @@ -134,6 +137,18 @@ impl RadrootsAppBackend for AndroidBackend { Ok(HomeActionResult::None) } } + + fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> { + #[cfg(target_os = "android")] + { + return Self::poll_recovery_key_export(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } } #[cfg(any(target_os = "android", test))] @@ -222,6 +237,47 @@ impl AndroidBackend { Ok(identity.nsec()) } + #[cfg(target_os = "android")] + fn begin_recovery_key_export() -> Result<(), String> { + security::begin_user_presence_verification("reveal the current recovery key") + .map_err(|source| source.to_string()) + } + + #[cfg(not(target_os = "android"))] + fn begin_recovery_key_export() -> Result<(), String> { + Ok(()) + } + + #[cfg(target_os = "android")] + fn recovery_key_export_pending() -> bool { + security::is_user_presence_verification_pending().unwrap_or(false) + } + + #[cfg(not(target_os = "android"))] + fn recovery_key_export_pending() -> bool { + false + } + + #[cfg(target_os = "android")] + fn poll_recovery_key_export() -> Result<Option<HomeActionResult>, String> { + match security::take_user_presence_verification_result() + .map_err(|source| source.to_string())? + { + Some(security::AndroidUserPresenceVerificationResult::Verified) => { + let manager = Self::accounts_manager()?; + Self::export_selected_local_recovery_key(&manager) + .map(|nsec| Some(HomeActionResult::RevealRecoveryKey { nsec })) + } + Some(security::AndroidUserPresenceVerificationResult::Failed(message)) => Err(message), + None => Ok(None), + } + } + + #[cfg(not(target_os = "android"))] + fn poll_recovery_key_export() -> Result<Option<HomeActionResult>, String> { + Ok(None) + } + fn remove_selected_local_identity( manager: &RadrootsNostrAccountsManager, ) -> Result<IdentityGateState, String> { diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs @@ -39,6 +39,34 @@ impl AndroidSecretStatus { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AndroidUserPresenceVerificationResult { + Verified, + Failed(String), +} + +#[cfg(target_os = "android")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AndroidUserPresenceResultStatus { + None, + Success, + Error, +} + +#[cfg(target_os = "android")] +impl AndroidUserPresenceResultStatus { + fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Success), + 2 => Ok(Self::Error), + other => Err(RadrootsNostrAccountsError::Vault(format!( + "unknown android user presence status {other}" + ))), + } + } +} + #[cfg(target_os = "android")] pub(crate) fn store_secret( service: &str, @@ -252,6 +280,117 @@ pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccou Ok(PathBuf::from(path)) } +#[cfg(target_os = "android")] +pub(crate) fn begin_user_presence_verification( + reason: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let java_vm = android_java_vm()?; + let mut env = java_vm.attach_current_thread().map_err(jni_error)?; + let bridge_class = bridge_class(&mut env)?; + let reason = java_string_arg(&mut env, reason)?; + + let status = env + .call_static_method( + &bridge_class, + "beginUserPresenceVerification", + "(Ljava/lang/String;)I", + &[JValue::Object(&reason)], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidSecretStatus::from_raw(status)? { + AndroidSecretStatus::Success => Ok(()), + AndroidSecretStatus::NotFound => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge reported no user presence result", + )), + AndroidSecretStatus::InvalidInput => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android security bridge rejected the user presence request", + )), + AndroidSecretStatus::Error => Err(bridge_vault_error( + &mut env, + &bridge_class, + "android user presence verification failed to start", + )), + } +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn begin_user_presence_verification( + reason: &str, +) -> Result<(), RadrootsNostrAccountsError> { + let _ = reason; + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub(crate) fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> { + let java_vm = android_java_vm()?; + let mut env = java_vm.attach_current_thread().map_err(jni_error)?; + let bridge_class = bridge_class(&mut env)?; + + env.call_static_method( + &bridge_class, + "isUserPresenceVerificationPending", + "()Z", + &[], + ) + .and_then(|value| value.z()) + .map_err(jni_error) +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + +#[cfg(target_os = "android")] +pub(crate) fn take_user_presence_verification_result() +-> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> { + let java_vm = android_java_vm()?; + let mut env = java_vm.attach_current_thread().map_err(jni_error)?; + let bridge_class = bridge_class(&mut env)?; + + let status = env + .call_static_method( + &bridge_class, + "takeUserPresenceVerificationResult", + "()I", + &[], + ) + .and_then(|value| value.i()) + .map_err(jni_error)?; + + match AndroidUserPresenceResultStatus::from_raw(status)? { + AndroidUserPresenceResultStatus::None => Ok(None), + AndroidUserPresenceResultStatus::Success => { + Ok(Some(AndroidUserPresenceVerificationResult::Verified)) + } + AndroidUserPresenceResultStatus::Error => { + Ok(Some(AndroidUserPresenceVerificationResult::Failed( + take_last_error_message(&mut env, &bridge_class)? + .unwrap_or_else(|| "android device authentication failed".to_owned()), + ))) + } + } +} + +#[cfg(not(target_os = "android"))] +pub(crate) fn take_user_presence_verification_result() +-> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> { + Err(RadrootsNostrAccountsError::Vault( + "android user presence verification is only available on android".to_owned(), + )) +} + #[cfg(not(target_os = "android"))] #[allow(dead_code)] pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -67,6 +67,9 @@ pub trait RadrootsAppBackend { fn request_home_action(&self, _action: HomeActionKind) -> Result<HomeActionResult, String> { Ok(HomeActionResult::None) } + fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> { + Ok(None) + } fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { Ok(None) } @@ -169,18 +172,24 @@ impl RadrootsApp { self.status_message = None; self.revealed_recovery_key = None; match self.backend.request_home_action(action) { - Ok(HomeActionResult::IdentityState(state)) => self.apply_identity_state(state), - Ok(HomeActionResult::RevealRecoveryKey { nsec }) => { - self.revealed_recovery_key = Some(nsec); - self.pending_home_confirmation = None; - } - Ok(HomeActionResult::None) => {} + Ok(result) => self.apply_home_action_result(result), Err(err) => { self.status_message = Some(err); } } } + fn apply_home_action_result(&mut self, result: HomeActionResult) { + match result { + HomeActionResult::IdentityState(state) => self.apply_identity_state(state), + HomeActionResult::RevealRecoveryKey { nsec } => { + self.revealed_recovery_key = Some(nsec); + self.pending_home_confirmation = None; + } + HomeActionResult::None => {} + } + } + fn home_action_requires_confirmation(action: HomeActionKind) -> bool { !matches!(action, HomeActionKind::BackupRecoveryKey) } @@ -203,6 +212,13 @@ impl RadrootsApp { } fn sync_backend(&mut self) { + match self.backend.poll_home_action_result() { + Ok(Some(result)) => self.apply_home_action_result(result), + Ok(None) => {} + Err(err) => { + self.status_message = Some(err); + } + } match self.backend.poll_identity_state() { Ok(Some(state)) => self.apply_identity_state(state), Ok(None) => {} @@ -380,6 +396,7 @@ mod tests { request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, recovery_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>, + home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>, poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>, } @@ -398,6 +415,7 @@ mod tests { request: Rc::new(RefCell::new(request.into())), recovery_request: Rc::new(RefCell::new(VecDeque::new())), home_request: Rc::new(RefCell::new(VecDeque::new())), + home_poll: Rc::new(RefCell::new(VecDeque::new())), poll: Rc::new(RefCell::new(poll.into())), } } @@ -427,6 +445,14 @@ mod tests { ); self } + + fn with_home_action_poll( + self, + poll: Vec<Result<Option<HomeActionResult>, String>>, + ) -> Self { + self.home_poll.borrow_mut().extend(poll); + self + } } impl RadrootsAppBackend for MockBackend { @@ -477,6 +503,10 @@ mod tests { response } + fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> { + self.home_poll.borrow_mut().pop_front().unwrap_or(Ok(None)) + } + fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { self.poll.borrow_mut().pop_front().unwrap_or(Ok(None)) } @@ -824,4 +854,44 @@ mod tests { assert_eq!(app.pending_home_confirmation, None); assert_eq!(app.revealed_recovery_key.as_deref(), Some("nsec1example")); } + + #[test] + fn deferred_backup_home_action_reveals_recovery_key_after_poll() { + let mut app = RadrootsApp::new(Box::new( + MockBackend::new( + Ok(IdentityGateState::Ready { + account_id: "abc".into(), + npub: "npub1abc".into(), + }), + vec![], + vec![], + SetupActionState { + label: "Generate New Key".into(), + enabled: true, + pending: false, + }, + ) + .with_home_action( + HomeActionState { + kind: HomeActionKind::BackupRecoveryKey, + label: "Back Up Recovery Key".into(), + enabled: true, + pending: true, + }, + vec![Ok(HomeActionResult::None)], + ) + .with_home_action_poll(vec![Ok(Some( + HomeActionResult::RevealRecoveryKey { + nsec: "nsec1example".into(), + }, + ))]), + )); + + app.request_home_action(HomeActionKind::BackupRecoveryKey); + assert_eq!(app.revealed_recovery_key, None); + + app.sync_backend(); + + assert_eq!(app.revealed_recovery_key.as_deref(), Some("nsec1example")); + } } diff --git a/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts b/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts @@ -27,5 +27,6 @@ android { } dependencies { + implementation("androidx.biometric:biometric:1.1.0") testImplementation("junit:junit:4.13.2") } diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt @@ -1,6 +1,7 @@ package org.radroots.app.android.security import android.content.Context +import androidx.fragment.app.FragmentActivity object RadRootsAndroidSecurityBridge { const val STATUS_SUCCESS = 0 @@ -8,15 +9,29 @@ object RadRootsAndroidSecurityBridge { const val STATUS_INVALID_INPUT = 2 const val STATUS_ERROR = 3 + const val USER_PRESENCE_RESULT_NONE = 0 + const val USER_PRESENCE_RESULT_SUCCESS = 1 + const val USER_PRESENCE_RESULT_ERROR = 2 + @Volatile private var applicationContext: Context? = null @Volatile + private var currentActivity: FragmentActivity? = null + + @Volatile private var lastErrorMessage: String? = null + @Volatile + private var userPresenceVerificationPending: Boolean = false + + @Volatile + private var userPresenceVerificationResult: Int = USER_PRESENCE_RESULT_NONE + @JvmStatic fun initialize(context: Context) { applicationContext = context.applicationContext + currentActivity = context as? FragmentActivity clearError() } @@ -93,6 +108,54 @@ object RadRootsAndroidSecurityBridge { } @JvmStatic + fun beginUserPresenceVerification(reason: String): Int { + return try { + if (reason.isBlank()) { + throw RadRootsAndroidSecurityError.InvalidInput("verification reason must not be blank") + } + if (userPresenceVerificationPending) { + throw RadRootsAndroidSecurityError.InvalidInput("device authentication is already in progress") + } + val activity = currentActivity + ?: throw RadRootsAndroidSecurityError.InvalidInput("android security bridge has no active activity") + + clearError() + userPresenceVerificationPending = true + userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE + + RadRootsAndroidUserPresenceVerifier(activity).beginVerification( + reason = reason, + onSuccess = { + clearError() + userPresenceVerificationPending = false + userPresenceVerificationResult = USER_PRESENCE_RESULT_SUCCESS + }, + onFailure = { cause -> + lastErrorMessage = cause.message ?: cause.toString() + userPresenceVerificationPending = false + userPresenceVerificationResult = USER_PRESENCE_RESULT_ERROR + }, + ) + + STATUS_SUCCESS + } catch (cause: Throwable) { + userPresenceVerificationPending = false + userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE + captureError(cause) + } + } + + @JvmStatic + fun isUserPresenceVerificationPending(): Boolean = userPresenceVerificationPending + + @JvmStatic + fun takeUserPresenceVerificationResult(): Int { + val result = userPresenceVerificationResult + userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE + return result + } + + @JvmStatic fun takeLastErrorMessage(): String? { val message = lastErrorMessage lastErrorMessage = null diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt @@ -13,4 +13,11 @@ sealed class RadRootsAndroidSecurityError( class StorageFailure(message: String, cause: Throwable? = null) : RadRootsAndroidSecurityError(message, cause) + + class UserCancelled(message: String) : RadRootsAndroidSecurityError(message) + + class UserPresenceUnavailable(message: String) : RadRootsAndroidSecurityError(message) + + class UserPresenceFailure(message: String, cause: Throwable? = null) : + RadRootsAndroidSecurityError(message, cause) } diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt @@ -0,0 +1,149 @@ +package org.radroots.app.android.security + +import android.app.KeyguardManager +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import androidx.core.content.ContextCompat + +class RadRootsAndroidUserPresenceVerifier( + private val activity: FragmentActivity, +) { + fun beginVerification( + reason: String, + onSuccess: () -> Unit, + onFailure: (RadRootsAndroidSecurityError) -> Unit, + ) { + if (reason.isBlank()) { + onFailure(RadRootsAndroidSecurityError.InvalidInput("verification reason must not be blank")) + return + } + + val promptInfo = try { + buildPromptInfo(reason) + } catch (error: RadRootsAndroidSecurityError) { + onFailure(error) + return + } + + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onFailure(mapAuthenticationError(errorCode, errString)) + } + + override fun onAuthenticationFailed() { + onFailure( + RadRootsAndroidSecurityError.UserPresenceFailure( + "device authentication failed", + ), + ) + } + }, + ) + + activity.runOnUiThread { + prompt.authenticate(promptInfo) + } + } + + private fun buildPromptInfo(reason: String): BiometricPrompt.PromptInfo { + ensureAuthenticationAvailable() + + val builder = BiometricPrompt.PromptInfo.Builder() + .setTitle("Rad Roots") + .setSubtitle("Authenticate to $reason") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + } else if (deviceCredentialAvailable()) { + builder.setDeviceCredentialAllowed(true) + } else { + builder.setNegativeButtonText("Cancel") + } + + return builder.build() + } + + private fun ensureAuthenticationAvailable() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when ( + BiometricManager.from(activity).canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + ) { + BiometricManager.BIOMETRIC_SUCCESS -> return + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + throw RadRootsAndroidSecurityError.UserPresenceUnavailable( + "no device authentication method is enrolled", + ) + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> + throw RadRootsAndroidSecurityError.UserPresenceUnavailable( + "device authentication is unavailable", + ) + else -> + throw RadRootsAndroidSecurityError.UserPresenceFailure( + "failed to prepare device authentication", + ) + } + } + + val biometricStatus = BiometricManager.from(activity).canAuthenticate() + if (biometricStatus == BiometricManager.BIOMETRIC_SUCCESS || deviceCredentialAvailable()) { + return + } + + throw when (biometricStatus) { + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + RadRootsAndroidSecurityError.UserPresenceUnavailable( + "no biometric or device credential is available", + ) + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> + RadRootsAndroidSecurityError.UserPresenceUnavailable( + "device authentication is unavailable", + ) + else -> + RadRootsAndroidSecurityError.UserPresenceFailure( + "failed to prepare device authentication", + ) + } + } + + private fun deviceCredentialAvailable(): Boolean { + val keyguardManager = activity.getSystemService(KeyguardManager::class.java) + return keyguardManager?.isDeviceSecure == true + } + + private fun mapAuthenticationError( + errorCode: Int, + errString: CharSequence, + ): RadRootsAndroidSecurityError { + val message = errString.toString() + return when (errorCode) { + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_CANCELED -> + RadRootsAndroidSecurityError.UserCancelled(message) + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> + RadRootsAndroidSecurityError.UserPresenceUnavailable(message) + else -> RadRootsAndroidSecurityError.UserPresenceFailure(message) + } + } +}