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/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..81e9e430 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,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 @@ -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 { @@ -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 @@ -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 diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 093a53d9..bf0464a7 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,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 @@ -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()) @@ -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)) @@ -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 @@ -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(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() @@ -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, @@ -1654,6 +1708,7 @@ fun MainScreen( }, onSecurityClick = { showSecuritySettings = true }, onBackupClick = { showBackupRestore = true }, + onApproverClick = openApprover, onSigningHistoryClick = { showHistoryScreen = true }, onClearLogsAndActivity = { withContext(Dispatchers.IO) { @@ -1934,6 +1989,7 @@ private fun SettingsTab( onForegroundServiceToggle: (Boolean) -> Unit, onSecurityClick: () -> Unit, onBackupClick: () -> Unit, + onApproverClick: () -> Unit, onSigningHistoryClick: () -> Unit, onClearLogsAndActivity: suspend () -> Unit ) { @@ -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)) 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..664b98f3 --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -0,0 +1,207 @@ +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 +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 java.security.SignatureException +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 serving the pairing could not start, until a start succeeds. */ + val startError: String? = null, +) + +/** 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) + } + + /** + * 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() { + 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() + } + + 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) + } + 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 -> { + // 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 -> notifier.requestWaiting() + is ApproverEvent.RequestRefused -> + notice(context.getString(R.string.approver_notice_request_refused, event.reason)) + is ApproverEvent.Outcome -> + notice(context.getString(R.string.approver_notice_outcome, event.outcome)) + is ApproverEvent.Anchor -> Unit + is ApproverEvent.Alert -> notifier.alert() + } + } + + 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 { + 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) + } + + 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) + reload() + } + + 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() + _state.update { it.copy(startError = null) } + 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 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) + 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 new file mode 100644 index 00000000..2fbd9068 --- /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 java.math.BigInteger +import java.security.InvalidAlgorithmParameterException +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: ProviderException) { + delete() + } catch (e: InvalidAlgorithmParameterException) { + 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..917a2b07 --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverNotifier.kt @@ -0,0 +1,74 @@ +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) + + fun clearAlert() = manager.cancel(ALERT_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..311bfeed --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt @@ -0,0 +1,487 @@ +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.DisposableEffect +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.setSecureScreen +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? + +/** 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) } + var begun by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var confirmForget by remember { mutableStateOf(false) } + 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) + 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()) } + + 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 + } + } + } + + // 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, subtitle: String = promptSubtitle): ByteArray? = + controller.sign(digest) { authenticate(it, title, 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, pairTitle) + ?: return@run + controller.finishPairing(signature) + begun = null + } + }, + onCancel = { begun = null } + ) + status == null || (!status.paired && !status.pairing) -> Unpaired( + label = label, + onLabelChange = { label = it }, + busy = busy, + onScan = { scanning = true } + ) + else -> { + 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, + onFreeze = { credentialId -> freeze(credentialId) }, + onDecide = { approve -> + run { + 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.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 { if (confirmOwner(forgetAuthTitle)) 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 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()) { + 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, + onFreeze: (String) -> Unit, + 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)) + } + } + 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)) } + } + } + } +} + +@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..3c611423 --- /dev/null +++ b/app/src/main/res/values/strings_approver.xml @@ -0,0 +1,64 @@ + + + + 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 this agent + 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 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 + 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..5ad9aaed --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/approver/ApproverKeyTest.kt @@ -0,0 +1,80 @@ +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, + credentialId = 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..898b2dfe 100644 --- a/keep.version +++ b/keep.version @@ -1 +1 @@ -d90607d589b82a598905126cedc2e49b1677d337 +2ebfed2086688ab880ff0f58bb8f7c7dd3acaf9e