From 42621dd4e3bfaf418a37676574804d52cef0fd0f Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 29 Oct 2026 18:31:12 -0400 Subject: [PATCH 1/4] Add the agent approval approver --- .../kotlin/io/privkey/keep/BiometricHelper.kt | 37 ++ .../kotlin/io/privkey/keep/KeepMobileApp.kt | 21 + .../kotlin/io/privkey/keep/MainActivity.kt | 49 +- .../kotlin/io/privkey/keep/SettingsCards.kt | 12 + .../keep/approver/ApproverController.kt | 199 +++++++++ .../io/privkey/keep/approver/ApproverKey.kt | 122 +++++ .../privkey/keep/approver/ApproverNotifier.kt | 72 +++ .../privkey/keep/approver/ApproverScreen.kt | 420 ++++++++++++++++++ app/src/main/res/values/strings_approver.xml | 55 +++ .../privkey/keep/approver/ApproverKeyTest.kt | 79 ++++ keep.version | 2 +- 11 files changed, 1065 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt create mode 100644 app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt create mode 100644 app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt create mode 100644 app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt create mode 100644 app/src/main/res/values/strings_approver.xml create mode 100644 app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt diff --git a/app/src/main/kotlin/io/privkey/keep/BiometricHelper.kt b/app/src/main/kotlin/io/privkey/keep/BiometricHelper.kt index 793121f8..40083a79 100644 --- a/app/src/main/kotlin/io/privkey/keep/BiometricHelper.kt +++ b/app/src/main/kotlin/io/privkey/keep/BiometricHelper.kt @@ -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 @@ -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?, diff --git a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt index 875d5c4f..d45b3fa5 100644 --- a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt +++ b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt @@ -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 @@ -82,6 +83,7 @@ class KeepMobileApp : Application() { // while onCreate is still running. @Volatile private var nip55Handler: Nip55Handler? = null + private var approverController: ApproverController? = null private var permissionStore: PermissionStore? = null private var callerVerificationStore: CallerVerificationStore? = null private var autoSigningSafeguards: AutoSigningSafeguards? = null @@ -146,6 +148,7 @@ class KeepMobileApp : Application() { migrateKillSwitch(newKeepMobile) keepMobile = newKeepMobile nip55Handler = Nip55Handler(newKeepMobile) + initializeApprover(newKeepMobile) newKeepMobile.setStateCallback(object : KeepStateCallback { override fun onStateChanged(state: KeepLiveState) { mainHandler.post { @@ -164,6 +167,22 @@ class KeepMobileApp : Application() { } } + // The approver pairing belongs to the app install, not to an account, so it is + // never reset on an account switch. + private fun initializeApprover(mobile: KeepMobile) { + runCatching { + val controller = ApproverController(this, mobile) + 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) + } + } + // 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 @@ -301,6 +320,8 @@ class KeepMobileApp : Application() { fun getStorage(): AndroidKeystoreStorage? = storage + fun getApproverController(): ApproverController? = approverController + fun isSigningKilled(): Boolean { if (killSwitchMigrationFailed) return true val mobile = getKeepMobile() ?: return true diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 093a53d9..873de7af 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -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 @@ -38,6 +39,10 @@ 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.descriptor.DescriptorSessionManager import io.privkey.keep.descriptor.WalletDescriptorScreen import io.privkey.keep.navigation.Route @@ -73,10 +78,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()) @@ -303,7 +315,13 @@ 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) + } ) } else { ErrorScreen(app.getInitError() ?: stringResource(R.string.main_init_app_failed)) @@ -369,7 +387,11 @@ 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 } ) { val appContext = LocalContext.current.applicationContext val biometricAvailable = biometricStatus == BiometricHelper.BiometricStatus.AVAILABLE @@ -449,6 +471,13 @@ fun MainScreen( var biometricTimeout by remember { mutableStateOf(biometricTimeoutStore.getTimeout()) } var biometricLockOnLaunch by remember { mutableStateOf(biometricTimeoutStore.isLockOnLaunchEnabled()) } var showBunkerScreen by remember { mutableStateOf(false) } + var showApproverScreen by remember { mutableStateOf(false) } + LaunchedEffect(approverRequested) { + if (approverRequested) { + showApproverScreen = approverController != null + onApproverRequestHandled() + } + } var showWalletDescriptorScreen by remember { mutableStateOf(false) } var descriptorCount by remember { mutableIntStateOf(0) } val bunkerUrl by BunkerService.bunkerUrl.collectAsState() @@ -1000,6 +1029,15 @@ fun MainScreen( return } + if (showApproverScreen && approverController != null) { + ApproverScreen( + controller = approverController, + authenticate = authenticateApprover, + onDismiss = { showApproverScreen = false } + ) + return + } + if (showBunkerScreen) { BunkerScreen( keepMobile = keepMobile, @@ -1654,6 +1692,7 @@ fun MainScreen( }, onSecurityClick = { showSecuritySettings = true }, onBackupClick = { showBackupRestore = true }, + onApproverClick = approverController?.let { { showApproverScreen = true } }, onSigningHistoryClick = { showHistoryScreen = true }, onClearLogsAndActivity = { withContext(Dispatchers.IO) { @@ -1934,6 +1973,7 @@ private fun SettingsTab( onForegroundServiceToggle: (Boolean) -> Unit, onSecurityClick: () -> Unit, onBackupClick: () -> Unit, + onApproverClick: (() -> Unit)?, onSigningHistoryClick: () -> Unit, onClearLogsAndActivity: suspend () -> Unit ) { @@ -1999,6 +2039,11 @@ private fun SettingsTab( BackupSettingsCard(onClick = onBackupClick) Spacer(modifier = Modifier.height(16.dp)) + if (onApproverClick != null) { + ApproverSettingsCard(onClick = onApproverClick) + Spacer(modifier = Modifier.height(16.dp)) + } + if (hasShare) { SigningHistoryCard(onClick = onSigningHistoryClick) Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/io/privkey/keep/SettingsCards.kt b/app/src/main/kotlin/io/privkey/keep/SettingsCards.kt index 9248c84a..934c0b43 100644 --- a/app/src/main/kotlin/io/privkey/keep/SettingsCards.kt +++ b/app/src/main/kotlin/io/privkey/keep/SettingsCards.kt @@ -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) { diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt new file mode 100644 index 00000000..7cc3556d --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -0,0 +1,199 @@ +package io.privkey.keep.approver + +import android.content.Context +import android.os.SystemClock +import android.security.keystore.KeyPermanentlyInvalidatedException +import io.privkey.keep.R +import io.privkey.keep.uniffi.ApprovalRequest +import io.privkey.keep.uniffi.ApproverCallbacks +import io.privkey.keep.uniffi.ApproverEvent +import io.privkey.keep.uniffi.ApproverHandler +import io.privkey.keep.uniffi.ApproverStatus +import io.privkey.keep.uniffi.KeepMobile +import io.privkey.keep.uniffi.KeepMobileException +import io.privkey.keep.uniffi.PairingStart +import java.security.Signature +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext + +/** A request waiting for the owner, with its deadline on the phone's monotonic clock. */ +data class WaitingRequest(val request: ApprovalRequest, val deadlineElapsedMs: Long) { + fun secondsLeft(nowElapsedMs: Long = SystemClock.elapsedRealtime()): Long = + ((deadlineElapsedMs - nowElapsedMs) / 1000).coerceAtLeast(0) +} + +data class ApproverState( + val status: ApproverStatus? = null, + val requests: List = emptyList(), +) + +/** Why an approver key signature could not be made. */ +class ApproverKeyInvalidatedException : Exception() + +/** + * The app's side of agent approvals: holds the one [ApproverHandler] for the process, + * turns its events into state for the screen, and posts a notification that only says + * something is waiting. Every approver key signature goes through [sign]. + */ +class ApproverController(private val context: Context, mobile: KeepMobile) { + private val handler = ApproverHandler(mobile) + private val notifier = ApproverNotifier(context) + + private val _state = MutableStateFlow(ApproverState()) + val state: StateFlow = _state.asStateFlow() + + private val _notices = MutableSharedFlow(extraBufferCapacity = 16) + val notices: SharedFlow = _notices.asSharedFlow() + + private val callbacks = object : ApproverCallbacks { + override fun onEvent(event: ApproverEvent) = handle(event) + } + + /** Resumes a pairing kept from an earlier run. Blocking. */ + fun startIfPaired() { + val status = handler.status() + if (status.paired || status.pairing) { + runCatching { handler.start(callbacks) } + } + reload() + if (_state.value.requests.isNotEmpty()) notifier.requestWaiting() + } + + private fun reload() { + val now = SystemClock.elapsedRealtime() + val waiting = handler.waitingRequests().map { + WaitingRequest(it, now + it.secondsLeft.toLong() * 1000) + } + _state.value = ApproverState(handler.status(), waiting) + } + + private fun handle(event: ApproverEvent) { + when (event) { + is ApproverEvent.Paired -> notice(R.string.approver_notice_paired) + is ApproverEvent.PairingRefused -> { + ApproverKey.delete() + notice(context.getString(R.string.approver_notice_pairing_refused, event.reason)) + } + is ApproverEvent.Request -> { + val deadline = SystemClock.elapsedRealtime() + event.request.secondsLeft.toLong() * 1000 + _state.update { s -> + s.copy( + requests = s.requests.filterNot { it.request.requestId == event.request.requestId } + + WaitingRequest(event.request, deadline) + ) + } + notifier.requestWaiting() + return + } + is ApproverEvent.RequestRefused -> + notice(context.getString(R.string.approver_notice_request_refused, event.reason)) + is ApproverEvent.Outcome -> { + removeRequest(event.requestId) + notice(context.getString(R.string.approver_notice_outcome, event.outcome)) + return + } + is ApproverEvent.Anchor -> Unit + is ApproverEvent.Alert -> notifier.alert() + } + reload() + } + + private fun removeRequest(requestId: String) { + _state.update { s -> s.copy(requests = s.requests.filterNot { it.request.requestId == requestId }) } + if (_state.value.requests.isEmpty()) notifier.clearRequests() + } + + private fun notice(resId: Int) = notice(context.getString(resId)) + + private fun notice(text: String) { + _notices.tryEmit(text) + } + + /** + * Has the Keystore sign [digest] with the approver key, once [authenticate] has unlocked + * the Signature with a strong biometric. Returns the DER signature, or null when the + * owner cancels. + */ + suspend fun sign( + digest: ByteArray, + authenticate: suspend (Signature) -> Signature?, + ): ByteArray? { + val signer = withContext(Dispatchers.IO) { + try { + ApproverKey.signer() + } catch (e: KeyPermanentlyInvalidatedException) { + throw ApproverKeyInvalidatedException() + } + } + val unlocked = authenticate(signer) ?: return null + return withContext(Dispatchers.IO) { + unlocked.update(digest) + unlocked.sign() + } + } + + suspend fun beginPairing(uri: String, label: String): PairingStart = io { + check(!handler.status().paired) { "already paired" } + val challenge = handler.pairingAttestationChallenge(uri) + ApproverKey.generate(context, challenge) + handler.beginPairing(uri, ApproverKey.publicKey(), label) + } + + suspend fun finishPairing(signature: ByteArray) = io { + try { + handler.finishPairing(signature, ApproverKey.attestation(), callbacks) + } finally { + reload() + } + } + + suspend fun decisionDigest(requestId: String, approve: Boolean): ByteArray = + io { handler.decisionDigest(requestId, approve) } + + suspend fun sendDecision(requestId: String, approve: Boolean, signature: ByteArray) = io { + handler.sendDecision(requestId, approve, signature) + removeRequest(requestId) + } + + suspend fun freeze(credential: String?) = io { + handler.freeze(credential) + reload() + } + + suspend fun unfreezeDigest(credential: String?): ByteArray = io { handler.unfreezeDigest(credential) } + + suspend fun sendUnfreeze(credential: String?, signature: ByteArray) = io { + handler.sendUnfreeze(credential, signature) + reload() + } + + suspend fun acknowledgeAlerts(throughSeq: ULong) = io { + handler.acknowledgeAlerts(throughSeq) + reload() + } + + suspend fun forget() = io { + handler.forget() + ApproverKey.delete() + notifier.clearRequests() + reload() + } + + private suspend fun io(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + companion object { + /** The text to show for a failed approver call. */ + fun errorText(context: Context, e: Throwable): String = when (e) { + is KeepMobileException.InvalidInput -> e.msg + is ApproverKeyInvalidatedException -> context.getString(R.string.approver_error_key_invalidated) + else -> context.getString(R.string.approver_error_generic) + } + } +} diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt new file mode 100644 index 00000000..9e906d5e --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt @@ -0,0 +1,122 @@ +package io.privkey.keep.approver + +import android.content.Context +import android.content.pm.PackageManager +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.ProviderException +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +/** + * The approver key: a P-256 signing key that never leaves the Keystore, each signature + * needing a fresh strong biometric. Made anew for each pairing, since the attestation + * challenge is per pairing code. + */ +internal object ApproverKey { + private const val KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "keep_approver_p256" + private const val PROBE_ALIAS = "keep_approver_strongbox_probe" + private const val ALGORITHM = "SHA256withECDSA" + + private fun keyStore(): KeyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + + /** Replaces any approver key with a new one attested over [challenge]. */ + fun generate(context: Context, challenge: ByteArray) { + delete() + val strongBox = context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) && + strongBoxSigns() + if (strongBox) { + try { + generate(challenge, strongBox = true) + return + } catch (e: StrongBoxUnavailableException) { + delete() + } catch (e: ProviderException) { + delete() + } + } + generate(challenge, strongBox = false) + } + + private fun generate(challenge: ByteArray, strongBox: Boolean) { + val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + .setUserAuthenticationRequired(true) + .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) + .setInvalidatedByBiometricEnrollment(true) + .setAttestationChallenge(challenge) + .setIsStrongBoxBacked(strongBox) + .build() + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE).run { + initialize(spec) + generateKeyPair() + } + } + + // Some StrongBox implementations make a key and then fail to use it, so a throwaway key + // with the same algorithm, without user authentication, must sign before StrongBox is used. + private fun strongBoxSigns(): Boolean = try { + val spec = KeyGenParameterSpec.Builder(PROBE_ALIAS, KeyProperties.PURPOSE_SIGN) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + .setIsStrongBoxBacked(true) + .build() + val pair = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE).run { + initialize(spec) + generateKeyPair() + } + Signature.getInstance(ALGORITHM).run { + initSign(pair.private) + update(ByteArray(32)) + sign() + } + true + } catch (e: Exception) { + false + } finally { + runCatching { keyStore().deleteEntry(PROBE_ALIAS) } + } + + /** The public key as 33 bytes, compressed SEC1. */ + fun publicKey(): ByteArray { + val key = keyStore().getCertificate(ALIAS)?.publicKey as? ECPublicKey + ?: throw IllegalStateException("no approver key") + return compress(key.w.affineX, key.w.affineY) + } + + /** The attestation chain, DER, leaf first. */ + fun attestation(): List = + keyStore().getCertificateChain(ALIAS)?.map { it.encoded } + ?: throw IllegalStateException("no approver key") + + /** + * A Signature ready for the biometric prompt to unlock. Throws + * [android.security.keystore.KeyPermanentlyInvalidatedException] once a biometric + * enrollment has destroyed the key. + */ + fun signer(): Signature { + val key = keyStore().getKey(ALIAS, null) as? PrivateKey + ?: throw IllegalStateException("no approver key") + return Signature.getInstance(ALGORITHM).apply { initSign(key) } + } + + fun delete() { + runCatching { keyStore().deleteEntry(ALIAS) } + } + + internal fun compress(x: BigInteger, y: BigInteger): ByteArray { + val xBytes = x.toByteArray().let { if (it.size > 32) it.copyOfRange(it.size - 32, it.size) else it } + val out = ByteArray(33) + out[0] = if (y.testBit(0)) 0x03 else 0x02 + xBytes.copyInto(out, 33 - xBytes.size) + return out + } +} diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt new file mode 100644 index 00000000..e007ff81 --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt @@ -0,0 +1,72 @@ +package io.privkey.keep.approver + +import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import io.privkey.keep.MainActivity +import io.privkey.keep.R + +/** + * Says that a request is waiting or that the gateway needs attention, and nothing more: + * no request content, and no action that approves or denies. Tapping opens the app. + */ +internal class ApproverNotifier(private val context: Context) { + private val manager = NotificationManagerCompat.from(context) + + init { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.approver_notification_channel), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = context.getString(R.string.approver_notification_channel_description) + } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + fun requestWaiting() = post(REQUEST_ID, R.string.approver_notification_request) + + fun alert() = post(ALERT_ID, R.string.approver_notification_alert) + + fun clearRequests() = manager.cancel(REQUEST_ID) + + @SuppressLint("MissingPermission") + private fun post(id: Int, textRes: Int) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) return + val intent = Intent(context, MainActivity::class.java) + .setAction(ACTION_SHOW_APPROVER) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val contentIntent = PendingIntent.getActivity( + context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val text = context.getString(textRes) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.approver_notification_title)) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .build() + manager.notify(id, notification) + } + + companion object { + const val ACTION_SHOW_APPROVER = "io.privkey.keep.action.SHOW_APPROVER" + private const val CHANNEL_ID = "agent_approvals" + private const val REQUEST_ID = 0x4B41 + private const val ALERT_ID = 0x4B42 + } +} diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt new file mode 100644 index 00000000..50b09f3e --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt @@ -0,0 +1,420 @@ +package io.privkey.keep.approver + +import android.os.SystemClock +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.privkey.keep.QrScannerScreen +import io.privkey.keep.R +import io.privkey.keep.ui.components.KeepScreenScaffold +import io.privkey.keep.uniffi.ApprovalField +import io.privkey.keep.uniffi.ApproverStatus +import io.privkey.keep.uniffi.PairingStart +import java.security.Signature +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val PAIRING_URI_PREFIX = "keep-approver:v1?" + +/** Unlocks a Signature with a strong biometric prompt titled and subtitled as given. */ +typealias ApproverAuthenticator = suspend (Signature, String, String) -> Signature? + +@Composable +fun ApproverScreen( + controller: ApproverController, + authenticate: ApproverAuthenticator, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by controller.state.collectAsState() + var scanning by remember { mutableStateOf(false) } + var begun by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var confirmForget by remember { mutableStateOf(false) } + var label by remember { mutableStateOf(context.getString(R.string.approver_default_label)) } + var now by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + + LaunchedEffect(Unit) { + controller.notices.collect { Toast.makeText(context, it, Toast.LENGTH_LONG).show() } + } + LaunchedEffect(Unit) { + while (true) { + now = SystemClock.elapsedRealtime() + delay(1000) + } + } + + fun run(action: suspend () -> Unit) { + if (busy) return + busy = true + scope.launch { + try { + action() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Toast.makeText(context, ApproverController.errorText(context, e), Toast.LENGTH_LONG).show() + } finally { + busy = false + } + } + } + + suspend fun sign(digest: ByteArray, title: String): ByteArray? = + controller.sign(digest) { authenticate(it, title, context.getString(R.string.approver_biometric_subtitle)) } + + if (scanning) { + BackHandler { scanning = false } + QrScannerScreen( + onCodeScanned = { uri -> + scanning = false + run { begun = controller.beginPairing(uri, label.trim()) } + }, + onDismiss = { scanning = false }, + validator = { it.startsWith(PAIRING_URI_PREFIX) }, + title = stringResource(R.string.approver_scan_title) + ) + return + } + + BackHandler(onBack = onDismiss) + KeepScreenScaffold(title = stringResource(R.string.approver_title), onBack = onDismiss) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val status = state.status + val pending = begun + when { + pending != null -> PairingConfirm( + start = pending, + busy = busy, + onConfirm = { + run { + val signature = sign(pending.digest, context.getString(R.string.approver_biometric_pair)) + ?: return@run + begun = null + controller.finishPairing(signature) + } + }, + onCancel = { begun = null } + ) + status == null || (!status.paired && !status.pairing) -> Unpaired( + label = label, + onLabelChange = { label = it }, + busy = busy, + onScan = { scanning = true } + ) + status.pairing -> PairingWaiting(status, busy, onCancel = { run { controller.forget() } }) + else -> { + FreezeControls( + status = status, + busy = busy, + onFreezeAll = { run { controller.freeze(null) } }, + onUnfreeze = { credential -> + run { + val digest = controller.unfreezeDigest(credential) + val signature = sign(digest, context.getString(R.string.approver_biometric_unfreeze)) + ?: return@run + controller.sendUnfreeze(credential, signature) + } + } + ) + Alerts(status, busy, onAcknowledge = { seq -> run { controller.acknowledgeAlerts(seq) } }) + if (state.requests.isEmpty()) { + Text( + stringResource(R.string.approver_no_requests), + style = MaterialTheme.typography.bodyMedium + ) + } + state.requests.forEach { waiting -> + RequestCard( + waiting = waiting, + secondsLeft = waiting.secondsLeft(now), + busy = busy, + onDecide = { approve -> + run { + val id = waiting.request.requestId + val digest = controller.decisionDigest(id, approve) + val title = context.getString( + if (approve) R.string.approver_biometric_approve else R.string.approver_biometric_deny + ) + val signature = sign(digest, title) ?: return@run + controller.sendDecision(id, approve, signature) + } + } + ) + } + PairingInfo(status) + OutlinedButton( + onClick = { confirmForget = true }, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.approver_forget)) } + } + } + } + } + + if (confirmForget) { + AlertDialog( + onDismissRequest = { confirmForget = false }, + title = { Text(stringResource(R.string.approver_forget)) }, + text = { Text(stringResource(R.string.approver_forget_body)) }, + confirmButton = { + TextButton(onClick = { + confirmForget = false + run { controller.forget() } + }) { Text(stringResource(R.string.approver_forget_confirm)) } + }, + dismissButton = { + TextButton(onClick = { confirmForget = false }) { Text(stringResource(R.string.common_cancel)) } + } + ) + } +} + +@Composable +private fun Unpaired(label: String, onLabelChange: (String) -> Unit, busy: Boolean, onScan: () -> Unit) { + Text(stringResource(R.string.approver_unpaired_body), style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = label, + onValueChange = { if (it.length <= 64) onLabelChange(it) }, + label = { Text(stringResource(R.string.approver_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = onScan, enabled = !busy && label.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.approver_scan)) + } +} + +@Composable +private fun PairingConfirm(start: PairingStart, busy: Boolean, onConfirm: () -> Unit, onCancel: () -> Unit) { + Text(stringResource(R.string.approver_fingerprint_body), style = MaterialTheme.typography.bodyMedium) + Fingerprint(start.fingerprint) + Button(onClick = onConfirm, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.approver_pair_sign)) + } + OutlinedButton(onClick = onCancel, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.common_cancel)) + } +} + +@Composable +private fun PairingWaiting(status: ApproverStatus, busy: Boolean, onCancel: () -> Unit) { + Text(stringResource(R.string.approver_pairing_waiting), style = MaterialTheme.typography.bodyMedium) + status.fingerprint?.let { Fingerprint(it) } + OutlinedButton(onClick = onCancel, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.approver_pairing_cancel)) + } +} + +@Composable +private fun Fingerprint(fingerprint: String) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + fingerprint, + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Composable +private fun FreezeControls( + status: ApproverStatus, + busy: Boolean, + onFreezeAll: () -> Unit, + onUnfreeze: (String?) -> Unit, +) { + Button( + onClick = onFreezeAll, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { Text(stringResource(R.string.approver_freeze_all)) } + if (status.allFrozen) { + Text(stringResource(R.string.approver_all_frozen), color = MaterialTheme.colorScheme.error) + OutlinedButton(onClick = { onUnfreeze(null) }, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.approver_unfreeze_all)) + } + } + status.frozenCredentials.forEach { id -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(R.string.approver_credential_frozen, id), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { onUnfreeze(id) }, enabled = !busy) { + Text(stringResource(R.string.approver_unfreeze)) + } + } + } + if (status.silent) { + Text(stringResource(R.string.approver_silent), color = MaterialTheme.colorScheme.error) + } +} + +@Composable +private fun Alerts(status: ApproverStatus, busy: Boolean, onAcknowledge: (ULong) -> Unit) { + val last = status.alerts.lastOrNull() ?: return + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.approver_alerts_title), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + status.alerts.forEach { Text(it.message, color = MaterialTheme.colorScheme.onErrorContainer) } + if (status.alertsDropped > 0u) { + Text( + stringResource(R.string.approver_alerts_dropped, status.alertsDropped.toLong()), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + TextButton(onClick = { onAcknowledge(last.seq) }, enabled = !busy) { + Text(stringResource(R.string.approver_alerts_acknowledge)) + } + } + } +} + +@Composable +private fun RequestCard( + waiting: WaitingRequest, + secondsLeft: Long, + busy: Boolean, + onDecide: (Boolean) -> Unit, +) { + val request = waiting.request + val expired = secondsLeft <= 0 + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(request.op, style = MaterialTheme.typography.titleMedium) + Text(request.reason, style = MaterialTheme.typography.bodyMedium) + request.credential?.let { + Text(stringResource(R.string.approver_request_credential, it), style = MaterialTheme.typography.bodyMedium) + } + Text( + if (expired) stringResource(R.string.approver_request_expired) + else stringResource(R.string.approver_request_seconds_left, secondsLeft), + color = if (secondsLeft <= 15) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + request.warnings.forEach { Text(it, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold) } + request.fields.filterNot { it.byAgent }.forEach { FieldRow(it) } + val agentFields = request.fields.filter { it.byAgent } + if (agentFields.isNotEmpty()) { + OutlinedCard( + border = BorderStroke(2.dp, MaterialTheme.colorScheme.tertiary), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.approver_written_by_agent), + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Bold + ) + agentFields.forEach { FieldRow(it) } + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { onDecide(false) }, enabled = !busy && !expired, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.approver_deny)) + } + Button(onClick = { onDecide(true) }, enabled = !busy && !expired, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.approver_approve)) + } + } + } + } +} + +@Composable +private fun FieldRow(field: ApprovalField) { + Column { + Text(field.label, style = MaterialTheme.typography.labelMedium) + Text(field.value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace) + if (field.removed > 0u) { + Text( + stringResource(R.string.approver_field_removed, field.removed.toInt()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + if (field.truncated) { + Text( + stringResource(R.string.approver_field_truncated, field.chars.toInt()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } +} + +@Composable +private fun PairingInfo(status: ApproverStatus) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + status.label?.let { Text(it, style = MaterialTheme.typography.titleMedium) } + status.fingerprint?.let { + Text(stringResource(R.string.approver_fingerprint, it), fontFamily = FontFamily.Monospace) + } + status.relays.forEach { Text(it, style = MaterialTheme.typography.bodySmall) } + status.epoch?.let { Text(stringResource(R.string.approver_epoch, it.toLong())) } + } + } +} diff --git a/app/src/main/res/values/strings_approver.xml b/app/src/main/res/values/strings_approver.xml new file mode 100644 index 00000000..9385517f --- /dev/null +++ b/app/src/main/res/values/strings_approver.xml @@ -0,0 +1,55 @@ + + + + Agent approvals + Approve or deny what your agents ask a Keep gateway to sign + Open + My phone + Name for this phone + Pair this phone with a Keep agent gateway. On the gateway host, run keep gateway approver pair and scan the code it shows. + Scan pairing code + Scan the gateway\'s pairing code + After you sign, the gateway\'s terminal shows a fingerprint. Confirm there only if it matches this one exactly. + Sign and pair + Waiting for the gateway. Confirm on its terminal that it shows this fingerprint. + Cancel pairing + Fingerprint: %1$s + Freeze epoch: %1$d + Freeze every agent + Every agent is frozen. + Unfreeze every agent + Frozen: %1$s + Unfreeze + No word from the gateway for an hour. It may be down, cut off, or its messages withheld. + The gateway needs your attention + %1$d older alerts were not kept. + I have read these + No requests are waiting. + Asked by: %1$s + %1$d seconds left + Expired, it can no longer be approved + Written by the agent, not checked by the gateway + %1$d hidden characters removed + Cut short, %1$d characters in full + Approve + Deny + Remove pairing + This phone forgets the gateway. The gateway keeps this device until it is removed there. + Remove + Signs with this phone\'s approver key + Pair with the gateway + Approve the request + Deny the request + Unfreeze + Paired with the gateway + The gateway refused the pairing: %1$s + A request failed its checks and was not shown: %1$s + Request %1$s + A biometric enrollment changed, so the approver key is gone. Remove this pairing and pair again. You can still freeze. + The approver could not do that + Agent approvals + Requests from agents waiting for your approval + Keep agent approvals + A request is waiting for you in the app + The gateway needs your attention + diff --git a/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt b/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt new file mode 100644 index 00000000..1d9eaf46 --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt @@ -0,0 +1,79 @@ +package io.privkey.keep.approver + +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class ApproverKeyTest { + private fun hex(s: String) = BigInteger(s, 16) + + @Test + fun compressesTheP256Generator() { + val x = hex("6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") + val y = hex("4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") + val expected = BigInteger("036b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", 16) + .toByteArray() + assertArrayEquals(expected, ApproverKey.compress(x, y)) + } + + @Test + fun dropsTheSignByteAndPadsShortCoordinates() { + val high = hex("f".repeat(64)) + val out = ApproverKey.compress(high, BigInteger.TWO) + assertEquals(33, out.size) + assertEquals(0x02.toByte(), out[0]) + assertArrayEquals(ByteArray(32) { 0xff.toByte() }, out.copyOfRange(1, 33)) + + val short = ApproverKey.compress(BigInteger.ONE, BigInteger.ONE) + assertEquals(0x03.toByte(), short[0]) + assertArrayEquals(ByteArray(31) + byteArrayOf(1), short.copyOfRange(1, 33)) + } + + @Test + fun roundTripsRandomKeysThroughTheCurve() { + val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp256r1")) } + .getParameterSpec(ECParameterSpec::class.java) + val p = (params.curve.field as java.security.spec.ECFieldFp).p + val generator = KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) } + repeat(32) { + val key = generator.generateKeyPair().public as ECPublicKey + val compressed = ApproverKey.compress(key.w.affineX, key.w.affineY) + val x = BigInteger(1, compressed.copyOfRange(1, 33)) + val rhs = x.pow(3).add(params.curve.a.multiply(x)).add(params.curve.b).mod(p) + var y = rhs.modPow(p.add(BigInteger.ONE).shiftRight(2), p) + if (y.testBit(0) != (compressed[0] == 0x03.toByte())) y = p.subtract(y) + val rebuilt = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(ECPoint(x, y), params)) + assertArrayEquals(key.encoded, rebuilt.encoded) + } + } +} + +class WaitingRequestTest { + private val request = io.privkey.keep.uniffi.ApprovalRequest( + requestId = "00".repeat(32), + op = "sign_nostr_event", + reason = "kind 0", + credential = null, + secondsLeft = 30u, + fields = emptyList(), + warnings = emptyList(), + ) + + @Test + fun countsDownOnTheMonotonicClockAndStopsAtZero() { + val waiting = WaitingRequest(request, deadlineElapsedMs = 30_000) + assertEquals(30, waiting.secondsLeft(0)) + assertEquals(1, waiting.secondsLeft(29_000)) + assertEquals(0, waiting.secondsLeft(30_000)) + assertEquals(0, waiting.secondsLeft(90_000)) + } +} diff --git a/keep.version b/keep.version index 056b0e94..84ceaac4 100644 --- a/keep.version +++ b/keep.version @@ -1 +1 @@ -d90607d589b82a598905126cedc2e49b1677d337 +b65f216f01f413288c8ecacd3aaf361d0ee1dde6 From 965d343c28bc7e5ce75a76dd4be505e057fd7ed7 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 29 Oct 2026 18:43:50 -0400 Subject: [PATCH 2/4] Test the approver signature prompt and show relay URL errors --- .../keep/BiometricHelperInstrumentedTest.kt | 19 +++++++++++++++++++ .../keep/approver/ApproverController.kt | 1 + 2 files changed, 20 insertions(+) diff --git a/app/src/androidTest/kotlin/io/privkey/keep/BiometricHelperInstrumentedTest.kt b/app/src/androidTest/kotlin/io/privkey/keep/BiometricHelperInstrumentedTest.kt index 6a18d88b..338c3deb 100644 --- a/app/src/androidTest/kotlin/io/privkey/keep/BiometricHelperInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/io/privkey/keep/BiometricHelperInstrumentedTest.kt @@ -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 /** @@ -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") } + } + } } diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt index 7cc3556d..766165ab 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -192,6 +192,7 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { /** The text to show for a failed approver call. */ fun errorText(context: Context, e: Throwable): String = when (e) { is KeepMobileException.InvalidInput -> e.msg + is KeepMobileException.InvalidRelayUrl -> e.msg is ApproverKeyInvalidatedException -> context.getString(R.string.approver_error_key_invalidated) else -> context.getString(R.string.approver_error_generic) } From 40f4dce6075917fbe1e8cd710f8639590877773f Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 29 Oct 2026 20:01:22 -0400 Subject: [PATCH 3/4] Pin keep with credential ids and offer freeze this agent --- .../keep/approver/ApproverController.kt | 4 ++ .../privkey/keep/approver/ApproverScreen.kt | 43 +++++++++++++++---- app/src/main/res/values/strings_approver.xml | 2 + .../privkey/keep/approver/ApproverKeyTest.kt | 1 + keep.version | 2 +- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt index 766165ab..f4753a1a 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -13,6 +13,7 @@ import io.privkey.keep.uniffi.KeepMobile import io.privkey.keep.uniffi.KeepMobileException import io.privkey.keep.uniffi.PairingStart import java.security.Signature +import java.security.SignatureException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -194,6 +195,9 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { is KeepMobileException.InvalidInput -> e.msg is KeepMobileException.InvalidRelayUrl -> e.msg is ApproverKeyInvalidatedException -> context.getString(R.string.approver_error_key_invalidated) + // A finger on the sensor before the prompt is shown can authenticate without + // authorizing this signature; the Keystore then refuses it. + is SignatureException -> context.getString(R.string.approver_error_try_again) else -> context.getString(R.string.approver_error_generic) } } diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt index 50b09f3e..ac003ba7 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt @@ -69,7 +69,13 @@ fun ApproverScreen( var begun by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var confirmForget by remember { mutableStateOf(false) } - var label by remember { mutableStateOf(context.getString(R.string.approver_default_label)) } + val defaultLabel = stringResource(R.string.approver_default_label) + val promptSubtitle = stringResource(R.string.approver_biometric_subtitle) + val pairTitle = stringResource(R.string.approver_biometric_pair) + val unfreezeTitle = stringResource(R.string.approver_biometric_unfreeze) + val approveTitle = stringResource(R.string.approver_biometric_approve) + val denyTitle = stringResource(R.string.approver_biometric_deny) + var label by remember { mutableStateOf(defaultLabel) } var now by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } LaunchedEffect(Unit) { @@ -98,8 +104,21 @@ fun ApproverScreen( } } + // Never gated on busy: stopping an agent must not wait behind a prompt or another call. + fun freeze(credentialId: String?) { + scope.launch { + try { + controller.freeze(credentialId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Toast.makeText(context, ApproverController.errorText(context, e), Toast.LENGTH_LONG).show() + } + } + } + suspend fun sign(digest: ByteArray, title: String): ByteArray? = - controller.sign(digest) { authenticate(it, title, context.getString(R.string.approver_biometric_subtitle)) } + controller.sign(digest) { authenticate(it, title, promptSubtitle) } if (scanning) { BackHandler { scanning = false } @@ -133,7 +152,7 @@ fun ApproverScreen( busy = busy, onConfirm = { run { - val signature = sign(pending.digest, context.getString(R.string.approver_biometric_pair)) + val signature = sign(pending.digest, pairTitle) ?: return@run begun = null controller.finishPairing(signature) @@ -152,11 +171,11 @@ fun ApproverScreen( FreezeControls( status = status, busy = busy, - onFreezeAll = { run { controller.freeze(null) } }, + onFreezeAll = { freeze(null) }, onUnfreeze = { credential -> run { val digest = controller.unfreezeDigest(credential) - val signature = sign(digest, context.getString(R.string.approver_biometric_unfreeze)) + val signature = sign(digest, unfreezeTitle) ?: return@run controller.sendUnfreeze(credential, signature) } @@ -174,14 +193,12 @@ fun ApproverScreen( waiting = waiting, secondsLeft = waiting.secondsLeft(now), busy = busy, + onFreeze = { credentialId -> freeze(credentialId) }, onDecide = { approve -> run { val id = waiting.request.requestId val digest = controller.decisionDigest(id, approve) - val title = context.getString( - if (approve) R.string.approver_biometric_approve else R.string.approver_biometric_deny - ) - val signature = sign(digest, title) ?: return@run + val signature = sign(digest, if (approve) approveTitle else denyTitle) ?: return@run controller.sendDecision(id, approve, signature) } } @@ -335,6 +352,7 @@ private fun RequestCard( waiting: WaitingRequest, secondsLeft: Long, busy: Boolean, + onFreeze: (String) -> Unit, onDecide: (Boolean) -> Unit, ) { val request = waiting.request @@ -379,6 +397,13 @@ private fun RequestCard( Text(stringResource(R.string.approver_approve)) } } + request.credentialId?.let { id -> + OutlinedButton( + onClick = { onFreeze(id) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text(stringResource(R.string.approver_freeze_agent)) } + } } } } diff --git a/app/src/main/res/values/strings_approver.xml b/app/src/main/res/values/strings_approver.xml index 9385517f..6f4973d8 100644 --- a/app/src/main/res/values/strings_approver.xml +++ b/app/src/main/res/values/strings_approver.xml @@ -15,6 +15,7 @@ Cancel pairing Fingerprint: %1$s Freeze epoch: %1$d + Freeze this agent Freeze every agent Every agent is frozen. Unfreeze every agent @@ -46,6 +47,7 @@ A request failed its checks and was not shown: %1$s Request %1$s A biometric enrollment changed, so the approver key is gone. Remove this pairing and pair again. You can still freeze. + The fingerprint did not unlock the key. Lift your finger and try again. The approver could not do that Agent approvals Requests from agents waiting for your approval diff --git a/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt b/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt index 1d9eaf46..5ad9aaed 100644 --- a/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt @@ -63,6 +63,7 @@ class WaitingRequestTest { op = "sign_nostr_event", reason = "kind 0", credential = null, + credentialId = null, secondsLeft = 30u, fields = emptyList(), warnings = emptyList(), diff --git a/keep.version b/keep.version index 84ceaac4..898b2dfe 100644 --- a/keep.version +++ b/keep.version @@ -1 +1 @@ -b65f216f01f413288c8ecacd3aaf361d0ee1dde6 +2ebfed2086688ab880ff0f58bb8f7c7dd3acaf9e From ea5e413e690ec7886531dd39da43048ccd231f37 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 29 Oct 2026 21:42:45 -0400 Subject: [PATCH 4/4] Address review findings on the agent approver --- .../kotlin/io/privkey/keep/KeepMobileApp.kt | 31 +++-- .../kotlin/io/privkey/keep/MainActivity.kt | 54 +++++--- .../keep/approver/ApproverController.kt | 67 +++++----- .../io/privkey/keep/approver/ApproverKey.kt | 6 +- .../privkey/keep/approver/ApproverNotifier.kt | 2 + .../privkey/keep/approver/ApproverScreen.kt | 124 ++++++++++++------ app/src/main/res/values/strings_approver.xml | 7 + 7 files changed, 183 insertions(+), 108 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt index d45b3fa5..81e9e430 100644 --- a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt +++ b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt @@ -83,6 +83,7 @@ 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 @@ -168,19 +169,24 @@ class KeepMobileApp : Application() { } // The approver pairing belongs to the app install, not to an account, so it is - // never reset on an account switch. - private fun initializeApprover(mobile: KeepMobile) { - runCatching { - val controller = ApproverController(this, mobile) - 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) + // 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) - } + .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), @@ -320,7 +326,8 @@ class KeepMobileApp : Application() { fun getStorage(): AndroidKeystoreStorage? = storage - fun getApproverController(): ApproverController? = approverController + fun getApproverController(): ApproverController? = + approverController ?: keepMobile?.let(::initializeApprover) fun isSigningKilled(): Boolean { if (killSwitchMigrationFailed) return true diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 873de7af..bf0464a7 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -43,6 +43,7 @@ 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 @@ -316,11 +317,17 @@ class MainActivity : FragmentActivity() { }, onDismissPinMismatch = app::dismissPinMismatch, onAccountSwitched = { stop -> app.onAccountSwitched(stop) }, - approverController = app.getApproverController(), + 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 { @@ -388,10 +395,11 @@ fun MainScreen( onRemoveCertificatePin: (String, String) -> CertPinRemoval? = { _, _ -> null }, onDismissPinMismatch: () -> Unit = {}, onAccountSwitched: suspend (Boolean) -> Boolean = { false }, - approverController: ApproverController? = null, + approverController: () -> ApproverController? = { null }, approverRequested: Boolean = false, onApproverRequestHandled: () -> Unit = {}, - authenticateApprover: ApproverAuthenticator = { _, _, _ -> null } + authenticateApprover: ApproverAuthenticator = { _, _, _ -> null }, + confirmApproverOwner: OwnerConfirmation = { false } ) { val appContext = LocalContext.current.applicationContext val biometricAvailable = biometricStatus == BiometricHelper.BiometricStatus.AVAILABLE @@ -471,10 +479,16 @@ fun MainScreen( var biometricTimeout by remember { mutableStateOf(biometricTimeoutStore.getTimeout()) } var biometricLockOnLaunch by remember { mutableStateOf(biometricTimeoutStore.isLockOnLaunchEnabled()) } var showBunkerScreen by remember { mutableStateOf(false) } - var showApproverScreen by remember { mutableStateOf(false) } + var approver by remember { mutableStateOf(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) { - showApproverScreen = approverController != null + openApprover() onApproverRequestHandled() } } @@ -773,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, @@ -1029,15 +1054,6 @@ fun MainScreen( return } - if (showApproverScreen && approverController != null) { - ApproverScreen( - controller = approverController, - authenticate = authenticateApprover, - onDismiss = { showApproverScreen = false } - ) - return - } - if (showBunkerScreen) { BunkerScreen( keepMobile = keepMobile, @@ -1692,7 +1708,7 @@ fun MainScreen( }, onSecurityClick = { showSecuritySettings = true }, onBackupClick = { showBackupRestore = true }, - onApproverClick = approverController?.let { { showApproverScreen = true } }, + onApproverClick = openApprover, onSigningHistoryClick = { showHistoryScreen = true }, onClearLogsAndActivity = { withContext(Dispatchers.IO) { @@ -1973,7 +1989,7 @@ private fun SettingsTab( onForegroundServiceToggle: (Boolean) -> Unit, onSecurityClick: () -> Unit, onBackupClick: () -> Unit, - onApproverClick: (() -> Unit)?, + onApproverClick: () -> Unit, onSigningHistoryClick: () -> Unit, onClearLogsAndActivity: suspend () -> Unit ) { @@ -2039,10 +2055,8 @@ private fun SettingsTab( BackupSettingsCard(onClick = onBackupClick) Spacer(modifier = Modifier.height(16.dp)) - if (onApproverClick != null) { - ApproverSettingsCard(onClick = onApproverClick) - Spacer(modifier = Modifier.height(16.dp)) - } + ApproverSettingsCard(onClick = onApproverClick) + Spacer(modifier = Modifier.height(16.dp)) if (hasShare) { SigningHistoryCard(onClick = onSigningHistoryClick) diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt index f4753a1a..664b98f3 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -3,6 +3,7 @@ package io.privkey.keep.approver import android.content.Context import android.os.SystemClock import android.security.keystore.KeyPermanentlyInvalidatedException +import io.privkey.keep.BiometricHelper import io.privkey.keep.R import io.privkey.keep.uniffi.ApprovalRequest import io.privkey.keep.uniffi.ApproverCallbacks @@ -33,6 +34,8 @@ data class WaitingRequest(val request: ApprovalRequest, val deadlineElapsedMs: L data class ApproverState( val status: ApproverStatus? = null, val requests: List = emptyList(), + /** Why serving the pairing could not start, until a start succeeds. */ + val startError: String? = null, ) /** Why an approver key signature could not be made. */ @@ -57,58 +60,54 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { override fun onEvent(event: ApproverEvent) = handle(event) } - /** Resumes a pairing kept from an earlier run. Blocking. */ + /** + * Shows the kept pairing, then starts serving it. Blocking: the start waits on the + * relays, so the state, freeze included, is published before it. + */ fun startIfPaired() { - val status = handler.status() - if (status.paired || status.pairing) { - runCatching { handler.start(callbacks) } - } + reload() + val status = _state.value.status ?: return + if (!status.paired && !status.pairing) return + val error = runCatching { handler.start(callbacks) }.exceptionOrNull() + _state.update { it.copy(startError = error?.let { e -> errorText(context, e) }) } reload() if (_state.value.requests.isNotEmpty()) notifier.requestWaiting() } - private fun reload() { + suspend fun retryStart() = io { startIfPaired() } + + // The core's state is the only truth: every change rereads all of it, and the read and + // the write happen together, so an older read can never overwrite a newer one. + private fun reload() = synchronized(this) { val now = SystemClock.elapsedRealtime() val waiting = handler.waitingRequests().map { WaitingRequest(it, now + it.secondsLeft.toLong() * 1000) } - _state.value = ApproverState(handler.status(), waiting) + val status = handler.status() + _state.update { it.copy(status = status, requests = waiting) } + if (waiting.isEmpty()) notifier.clearRequests() + if (status.alerts.isEmpty()) notifier.clearAlert() } private fun handle(event: ApproverEvent) { + reload() when (event) { is ApproverEvent.Paired -> notice(R.string.approver_notice_paired) is ApproverEvent.PairingRefused -> { - ApproverKey.delete() + // A gateway's refusal ends the pairing; a reply that fails its checks does not, + // and the key must stay for the pairing that goes on. + val status = _state.value.status + if (status != null && !status.paired && !status.pairing) ApproverKey.delete() notice(context.getString(R.string.approver_notice_pairing_refused, event.reason)) } - is ApproverEvent.Request -> { - val deadline = SystemClock.elapsedRealtime() + event.request.secondsLeft.toLong() * 1000 - _state.update { s -> - s.copy( - requests = s.requests.filterNot { it.request.requestId == event.request.requestId } + - WaitingRequest(event.request, deadline) - ) - } - notifier.requestWaiting() - return - } + is ApproverEvent.Request -> notifier.requestWaiting() is ApproverEvent.RequestRefused -> notice(context.getString(R.string.approver_notice_request_refused, event.reason)) - is ApproverEvent.Outcome -> { - removeRequest(event.requestId) + is ApproverEvent.Outcome -> notice(context.getString(R.string.approver_notice_outcome, event.outcome)) - return - } is ApproverEvent.Anchor -> Unit is ApproverEvent.Alert -> notifier.alert() } - reload() - } - - private fun removeRequest(requestId: String) { - _state.update { s -> s.copy(requests = s.requests.filterNot { it.request.requestId == requestId }) } - if (_state.value.requests.isEmpty()) notifier.clearRequests() } private fun notice(resId: Int) = notice(context.getString(resId)) @@ -141,7 +140,8 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { } suspend fun beginPairing(uri: String, label: String): PairingStart = io { - check(!handler.status().paired) { "already paired" } + val status = handler.status() + check(!status.paired && !status.pairing) { "already paired" } val challenge = handler.pairingAttestationChallenge(uri) ApproverKey.generate(context, challenge) handler.beginPairing(uri, ApproverKey.publicKey(), label) @@ -160,7 +160,7 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { suspend fun sendDecision(requestId: String, approve: Boolean, signature: ByteArray) = io { handler.sendDecision(requestId, approve, signature) - removeRequest(requestId) + reload() } suspend fun freeze(credential: String?) = io { @@ -183,7 +183,7 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { suspend fun forget() = io { handler.forget() ApproverKey.delete() - notifier.clearRequests() + _state.update { it.copy(startError = null) } reload() } @@ -198,6 +198,9 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { // A finger on the sensor before the prompt is shown can authenticate without // authorizing this signature; the Keystore then refuses it. is SignatureException -> context.getString(R.string.approver_error_try_again) + is BiometricHelper.BiometricException -> e.message ?: context.getString(R.string.approver_error_generic) + is KeepMobileException.NetworkException -> context.getString(R.string.approver_error_network, e.msg) + is KeepMobileException.CertificatePinMismatch -> context.getString(R.string.approver_error_pin_mismatch) else -> context.getString(R.string.approver_error_generic) } } diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt index 9e906d5e..2fbd9068 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverKey.kt @@ -4,8 +4,8 @@ import android.content.Context import android.content.pm.PackageManager import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.security.keystore.StrongBoxUnavailableException import java.math.BigInteger +import java.security.InvalidAlgorithmParameterException import java.security.KeyPairGenerator import java.security.KeyStore import java.security.PrivateKey @@ -36,10 +36,10 @@ internal object ApproverKey { try { generate(challenge, strongBox = true) return - } catch (e: StrongBoxUnavailableException) { - delete() } catch (e: ProviderException) { delete() + } catch (e: InvalidAlgorithmParameterException) { + delete() } } generate(challenge, strongBox = false) diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt index e007ff81..917a2b07 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt @@ -38,6 +38,8 @@ internal class ApproverNotifier(private val context: Context) { fun clearRequests() = manager.cancel(REQUEST_ID) + fun clearAlert() = manager.cancel(ALERT_ID) + @SuppressLint("MissingPermission") private fun post(id: Int, textRes: Int) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt index ac003ba7..311bfeed 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -42,6 +43,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.privkey.keep.QrScannerScreen import io.privkey.keep.R +import io.privkey.keep.setSecureScreen import io.privkey.keep.ui.components.KeepScreenScaffold import io.privkey.keep.uniffi.ApprovalField import io.privkey.keep.uniffi.ApproverStatus @@ -56,13 +58,23 @@ private const val PAIRING_URI_PREFIX = "keep-approver:v1?" /** Unlocks a Signature with a strong biometric prompt titled and subtitled as given. */ typealias ApproverAuthenticator = suspend (Signature, String, String) -> Signature? +/** Confirms the owner is present (strong biometric or the device credential), titled as given. */ +typealias OwnerConfirmation = suspend (String) -> Boolean + @Composable fun ApproverScreen( controller: ApproverController, authenticate: ApproverAuthenticator, + confirmOwner: OwnerConfirmation, + backgroundServiceEnabled: Boolean, onDismiss: () -> Unit, ) { val context = LocalContext.current + // Requests can carry what an agent would get, a NIP-44 plaintext holding money included. + DisposableEffect(context) { + setSecureScreen(context, true) + onDispose { setSecureScreen(context, false) } + } val scope = rememberCoroutineScope() val state by controller.state.collectAsState() var scanning by remember { mutableStateOf(false) } @@ -75,6 +87,8 @@ fun ApproverScreen( val unfreezeTitle = stringResource(R.string.approver_biometric_unfreeze) val approveTitle = stringResource(R.string.approver_biometric_approve) val denyTitle = stringResource(R.string.approver_biometric_deny) + val forgetAuthTitle = stringResource(R.string.approver_forget_auth) + val acknowledgeAuthTitle = stringResource(R.string.approver_alerts_acknowledge) var label by remember { mutableStateOf(defaultLabel) } var now by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } @@ -117,8 +131,8 @@ fun ApproverScreen( } } - suspend fun sign(digest: ByteArray, title: String): ByteArray? = - controller.sign(digest) { authenticate(it, title, promptSubtitle) } + suspend fun sign(digest: ByteArray, title: String, subtitle: String = promptSubtitle): ByteArray? = + controller.sign(digest) { authenticate(it, title, subtitle) } if (scanning) { BackHandler { scanning = false } @@ -154,8 +168,8 @@ fun ApproverScreen( run { val signature = sign(pending.digest, pairTitle) ?: return@run - begun = null controller.finishPairing(signature) + begun = null } }, onCancel = { begun = null } @@ -166,51 +180,65 @@ fun ApproverScreen( busy = busy, onScan = { scanning = true } ) - status.pairing -> PairingWaiting(status, busy, onCancel = { run { controller.forget() } }) else -> { - FreezeControls( - status = status, + if (!status.running) { + NotRunning(state.startError, busy, onRetry = { run { controller.retryStart() } }) + } + if (!backgroundServiceEnabled) { + Text(stringResource(R.string.approver_background_off), style = MaterialTheme.typography.bodySmall) + } + } + } + if (status != null && status.pairing && pending == null) { + PairingWaiting(status, busy, onCancel = { run { controller.forget() } }) + } else if (status != null && status.paired && pending == null) { + FreezeControls( + status = status, + busy = busy, + onFreezeAll = { freeze(null) }, + onUnfreeze = { credential -> + run { + val digest = controller.unfreezeDigest(credential) + val signature = sign(digest, unfreezeTitle) + ?: return@run + controller.sendUnfreeze(credential, signature) + } + } + ) + Alerts(status, busy, onAcknowledge = { seq -> + run { if (confirmOwner(acknowledgeAuthTitle)) controller.acknowledgeAlerts(seq) } + }) + if (state.requests.isEmpty()) { + Text( + stringResource(R.string.approver_no_requests), + style = MaterialTheme.typography.bodyMedium + ) + } + state.requests.forEach { waiting -> + RequestCard( + waiting = waiting, + secondsLeft = waiting.secondsLeft(now), busy = busy, - onFreezeAll = { freeze(null) }, - onUnfreeze = { credential -> + onFreeze = { credentialId -> freeze(credentialId) }, + onDecide = { approve -> run { - val digest = controller.unfreezeDigest(credential) - val signature = sign(digest, unfreezeTitle) + val id = waiting.request.requestId + val digest = controller.decisionDigest(id, approve) + val subtitle = listOfNotNull(waiting.request.op, waiting.request.credential) + .joinToString(" ยท ") + val signature = sign(digest, if (approve) approveTitle else denyTitle, subtitle) ?: return@run - controller.sendUnfreeze(credential, signature) + controller.sendDecision(id, approve, signature) } } ) - Alerts(status, busy, onAcknowledge = { seq -> run { controller.acknowledgeAlerts(seq) } }) - if (state.requests.isEmpty()) { - Text( - stringResource(R.string.approver_no_requests), - style = MaterialTheme.typography.bodyMedium - ) - } - state.requests.forEach { waiting -> - RequestCard( - waiting = waiting, - secondsLeft = waiting.secondsLeft(now), - busy = busy, - onFreeze = { credentialId -> freeze(credentialId) }, - onDecide = { approve -> - run { - val id = waiting.request.requestId - val digest = controller.decisionDigest(id, approve) - val signature = sign(digest, if (approve) approveTitle else denyTitle) ?: return@run - controller.sendDecision(id, approve, signature) - } - } - ) - } - PairingInfo(status) - OutlinedButton( - onClick = { confirmForget = true }, - enabled = !busy, - modifier = Modifier.fillMaxWidth() - ) { Text(stringResource(R.string.approver_forget)) } } + PairingInfo(status) + OutlinedButton( + onClick = { confirmForget = true }, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.approver_forget)) } } } } @@ -223,7 +251,7 @@ fun ApproverScreen( confirmButton = { TextButton(onClick = { confirmForget = false - run { controller.forget() } + run { if (confirmOwner(forgetAuthTitle)) controller.forget() } }) { Text(stringResource(R.string.approver_forget_confirm)) } }, dismissButton = { @@ -269,6 +297,20 @@ private fun PairingWaiting(status: ApproverStatus, busy: Boolean, onCancel: () - } } +@Composable +private fun NotRunning(error: String?, busy: Boolean, onRetry: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.approver_not_running), color = MaterialTheme.colorScheme.onErrorContainer) + error?.let { Text(it, color = MaterialTheme.colorScheme.onErrorContainer) } + TextButton(onClick = onRetry, enabled = !busy) { Text(stringResource(R.string.approver_retry)) } + } + } +} + @Composable private fun Fingerprint(fingerprint: String) { Card(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/res/values/strings_approver.xml b/app/src/main/res/values/strings_approver.xml index 6f4973d8..3c611423 100644 --- a/app/src/main/res/values/strings_approver.xml +++ b/app/src/main/res/values/strings_approver.xml @@ -48,6 +48,13 @@ Request %1$s A biometric enrollment changed, so the approver key is gone. Remove this pairing and pair again. You can still freeze. The fingerprint did not unlock the key. Lift your finger and try again. + Not sent: %1$s + A relay\'s certificate does not match its pin, so the approver will not connect. + The approver is not connected to the gateway\'s relays, so requests cannot arrive and a freeze will not go out until it is. + Try again + Keep only hears the gateway while it is running. Turn on the background service in Settings to get requests while Keep is closed. + Confirm removing the pairing + Agent approvals could not start. Its saved state could not be read. Try again in a moment. The approver could not do that Agent approvals Requests from agents waiting for your approval