Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
import java.security.Signature
import javax.crypto.Cipher

/**
Expand Down Expand Up @@ -90,4 +91,22 @@ class BiometricHelperInstrumentedTest {
runBlocking { helper.authenticateWithCrypto(uninitializedCipher()) }
}
}

private fun uninitializedSignature(): Signature = Signature.getInstance("SHA256withECDSA")

@Test
fun authenticateSignature_cancellation_returnsNull() = withActivity { activity ->
for (code in listOf(BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON)) {
val helper = BiometricHelper.withAuthenticator(activity, null, erroringAuthenticator(code))
assertNull(runBlocking { helper.authenticateSignature(uninitializedSignature(), "t", "s") })
}
}

@Test
fun authenticateSignature_nonCancellationError_throws() = withActivity { activity ->
val helper = BiometricHelper.withAuthenticator(activity, null, erroringAuthenticator(BiometricPrompt.ERROR_LOCKOUT))
assertThrows(BiometricHelper.BiometricException::class.java) {
runBlocking { helper.authenticateSignature(uninitializedSignature(), "t", "s") }
}
}
}
37 changes: 37 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/BiometricHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.privkey.keep.storage.BiometricTimeoutStore
import java.security.Signature
import javax.crypto.Cipher
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
Expand Down Expand Up @@ -187,6 +188,42 @@ class BiometricHelper private constructor(
}
}

/**
* Prompts for a strong biometric to unlock [signature], a Keystore signing key whose every
* use needs one. Returns the unlocked Signature, or null when the person cancels. Never
* short-circuited by, and never opens, the biometric timeout window.
*/
suspend fun authenticateSignature(
signature: Signature,
title: String,
subtitle: String
): Signature? {
val promptInfo = resolvePromptInfo(title, subtitle, null)
return suspendCoroutine { continuation ->
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
continuation.resume(result.cryptoObject?.signature)
}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
val isCancellation = errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON
if (isCancellation) {
continuation.resume(null)
} else {
continuation.resumeWithException(
BiometricException(errorCode, errString.toString())
)
}
}

override fun onAuthenticationFailed() {}
}

authenticator.authenticate(promptInfo, BiometricPrompt.CryptoObject(signature), callback)
}
}

private fun resolvePromptInfo(
title: String?,
subtitle: String?,
Expand Down
28 changes: 28 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.privkey.keep.approver.ApproverController
import io.privkey.keep.descriptor.DescriptorSessionManager
import io.privkey.keep.nip46.BunkerConfigStore
import io.privkey.keep.nip46.BunkerService
Expand Down Expand Up @@ -82,6 +83,8 @@ class KeepMobileApp : Application() {
// while onCreate is still running.
@Volatile
private var nip55Handler: Nip55Handler? = null
@Volatile
private var approverController: ApproverController? = null
private var permissionStore: PermissionStore? = null
private var callerVerificationStore: CallerVerificationStore? = null
private var autoSigningSafeguards: AutoSigningSafeguards? = null
Expand Down Expand Up @@ -146,6 +149,7 @@ class KeepMobileApp : Application() {
migrateKillSwitch(newKeepMobile)
keepMobile = newKeepMobile
nip55Handler = Nip55Handler(newKeepMobile)
initializeApprover(newKeepMobile)
newKeepMobile.setStateCallback(object : KeepStateCallback {
override fun onStateChanged(state: KeepLiveState) {
mainHandler.post {
Expand All @@ -164,6 +168,27 @@ class KeepMobileApp : Application() {
}
}

// The approver pairing belongs to the app install, not to an account, so it is
// never reset on an account switch. A state that cannot be read now (a Keystore
// fault at launch) leaves no controller, and the next lookup tries again.
@Synchronized
private fun initializeApprover(mobile: KeepMobile): ApproverController? {
approverController?.let { return it }
return runCatching { ApproverController(this, mobile) }
.onSuccess { controller ->
approverController = controller
applicationScope.launch(Dispatchers.IO) {
runCatching { controller.startIfPaired() }.onFailure { e ->
if (BuildConfig.DEBUG) Log.e(TAG, "Approver failed to start: ${e::class.simpleName}", e)
}
}
}
.onFailure { e ->
if (BuildConfig.DEBUG) Log.e(TAG, "Approver unavailable: ${e::class.simpleName}", e)
}
.getOrNull()
}

// Called only on the main thread (from the state callback's mainHandler.post),
// so the dedup fields need no synchronization. Logs relay status only on
// transitions and peers only on per-share status changes; the first callback
Expand Down Expand Up @@ -301,6 +326,9 @@ class KeepMobileApp : Application() {

fun getStorage(): AndroidKeystoreStorage? = storage

fun getApproverController(): ApproverController? =
approverController ?: keepMobile?.let(::initializeApprover)

fun isSigningKilled(): Boolean {
if (killSwitchMigrationFailed) return true
val mobile = getKeepMobile() ?: return true
Expand Down
63 changes: 61 additions & 2 deletions app/src/main/kotlin/io/privkey/keep/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.privkey.keep

import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Toast
Expand Down Expand Up @@ -38,6 +39,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import io.privkey.keep.approver.ApproverAuthenticator
import io.privkey.keep.approver.ApproverController
import io.privkey.keep.approver.ApproverNotifier
import io.privkey.keep.approver.ApproverScreen
import io.privkey.keep.approver.OwnerConfirmation
import io.privkey.keep.descriptor.DescriptorSessionManager
import io.privkey.keep.descriptor.WalletDescriptorScreen
import io.privkey.keep.navigation.Route
Expand Down Expand Up @@ -73,10 +79,17 @@ import javax.crypto.Cipher
private const val MAX_SEED_WORDS_LENGTH = 1024

class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == ApproverNotifier.ACTION_SHOW_APPROVER) approverRequested = true
}

private var biometricHelper: BiometricHelper? = null
private var approverRequested by mutableStateOf(false)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) approverRequested = intent?.action == ApproverNotifier.ACTION_SHOW_APPROVER

val app = application as? KeepMobileApp ?: run { finish(); return }
biometricHelper = BiometricHelper(this, app.getBiometricTimeoutStore())
Expand Down Expand Up @@ -303,7 +316,19 @@ class MainActivity : FragmentActivity() {
app.removeCertificatePin(hostname, spkiHash)
},
onDismissPinMismatch = app::dismissPinMismatch,
onAccountSwitched = { stop -> app.onAccountSwitched(stop) }
onAccountSwitched = { stop -> app.onAccountSwitched(stop) },
approverController = app::getApproverController,
approverRequested = approverRequested,
onApproverRequestHandled = { approverRequested = false },
authenticateApprover = { signature, title, subtitle ->
biometricHelper?.authenticateSignature(signature, title, subtitle)
},
confirmApproverOwner = { title ->
biometricHelper?.authenticateStrongOrDeviceCredential(
title,
getString(R.string.approver_biometric_subtitle)
) != null
}
)
} else {
ErrorScreen(app.getInitError() ?: stringResource(R.string.main_init_app_failed))
Expand Down Expand Up @@ -369,7 +394,12 @@ fun MainScreen(
onStageCertificatePin: (String, String) -> Boolean = { _, _ -> false },
onRemoveCertificatePin: (String, String) -> CertPinRemoval? = { _, _ -> null },
onDismissPinMismatch: () -> Unit = {},
onAccountSwitched: suspend (Boolean) -> Boolean = { false }
onAccountSwitched: suspend (Boolean) -> Boolean = { false },
approverController: () -> ApproverController? = { null },
approverRequested: Boolean = false,
onApproverRequestHandled: () -> Unit = {},
authenticateApprover: ApproverAuthenticator = { _, _, _ -> null },
confirmApproverOwner: OwnerConfirmation = { false }
) {
val appContext = LocalContext.current.applicationContext
val biometricAvailable = biometricStatus == BiometricHelper.BiometricStatus.AVAILABLE
Expand Down Expand Up @@ -449,6 +479,19 @@ fun MainScreen(
var biometricTimeout by remember { mutableStateOf(biometricTimeoutStore.getTimeout()) }
var biometricLockOnLaunch by remember { mutableStateOf(biometricTimeoutStore.isLockOnLaunchEnabled()) }
var showBunkerScreen by remember { mutableStateOf(false) }
var approver by remember { mutableStateOf<ApproverController?>(null) }
val openApprover = {
approver = approverController()
if (approver == null) {
Toast.makeText(appContext, appContext.getString(R.string.approver_unavailable), Toast.LENGTH_LONG).show()
}
}
LaunchedEffect(approverRequested) {
if (approverRequested) {
openApprover()
onApproverRequestHandled()
}
}
var showWalletDescriptorScreen by remember { mutableStateOf(false) }
var descriptorCount by remember { mutableIntStateOf(0) }
val bunkerUrl by BunkerService.bunkerUrl.collectAsState()
Expand Down Expand Up @@ -744,6 +787,17 @@ fun MainScreen(
)
}

approver?.let { controller ->
ApproverScreen(
controller = controller,
authenticate = authenticateApprover,
confirmOwner = confirmApproverOwner,
backgroundServiceEnabled = foregroundServiceEnabled,
onDismiss = { approver = null }
)
return
}

if (showPinSetup) {
PinSetupScreen(
pinStore = pinStore,
Expand Down Expand Up @@ -1654,6 +1708,7 @@ fun MainScreen(
},
onSecurityClick = { showSecuritySettings = true },
onBackupClick = { showBackupRestore = true },
onApproverClick = openApprover,
onSigningHistoryClick = { showHistoryScreen = true },
onClearLogsAndActivity = {
withContext(Dispatchers.IO) {
Expand Down Expand Up @@ -1934,6 +1989,7 @@ private fun SettingsTab(
onForegroundServiceToggle: (Boolean) -> Unit,
onSecurityClick: () -> Unit,
onBackupClick: () -> Unit,
onApproverClick: () -> Unit,
onSigningHistoryClick: () -> Unit,
onClearLogsAndActivity: suspend () -> Unit
) {
Expand Down Expand Up @@ -1999,6 +2055,9 @@ private fun SettingsTab(
BackupSettingsCard(onClick = onBackupClick)
Spacer(modifier = Modifier.height(16.dp))

ApproverSettingsCard(onClick = onApproverClick)
Spacer(modifier = Modifier.height(16.dp))

if (hasShare) {
SigningHistoryCard(onClick = onSigningHistoryClick)
Spacer(modifier = Modifier.height(16.dp))
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/SettingsCards.kt
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,18 @@ fun ExportLogsCard(onClick: () -> Unit) {
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApproverSettingsCard(onClick: () -> Unit) {
KeepCard(onClick = onClick) {
KeepListRow(
title = stringResource(R.string.approver_title),
subtitle = stringResource(R.string.approver_settings_subtitle),
trailing = { KeepRowAction(stringResource(R.string.approver_settings_action)) }
)
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupSettingsCard(onClick: () -> Unit) {
Expand Down
Loading