From 1091522190c240d5618237db9297c60c437aea1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 15 Jul 2026 13:24:04 -0400 Subject: [PATCH 1/2] keep-android: stage and retire backup certificate pins --- .../io/privkey/keep/CertificatePinning.kt | 57 ++++++++++ .../kotlin/io/privkey/keep/KeepMobileApp.kt | 6 + .../kotlin/io/privkey/keep/MainActivity.kt | 36 +++++- .../kotlin/io/privkey/keep/SecurityCards.kt | 105 ++++++++++++++++-- app/src/main/res/values/strings_settings.xml | 9 ++ .../privkey/keep/CertificatePinsCardTest.kt | 57 ++++++++++ keep.version | 2 +- 7 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 app/src/test/kotlin/io/privkey/keep/CertificatePinsCardTest.kt diff --git a/app/src/main/kotlin/io/privkey/keep/CertificatePinning.kt b/app/src/main/kotlin/io/privkey/keep/CertificatePinning.kt index 075cd0c0..07ac8e23 100644 --- a/app/src/main/kotlin/io/privkey/keep/CertificatePinning.kt +++ b/app/src/main/kotlin/io/privkey/keep/CertificatePinning.kt @@ -7,6 +7,25 @@ data class CertificatePin( val spkiHash: String ) +/** + * Outcome of retiring a single pin, mirroring the keep-mobile `CertPinRemovalResult`. + * [hostNowUnpinned] is true when the host has no pins left, so the next connection + * trusts-on-first-use and re-pins whatever certificate is presented. + */ +data class CertPinRemoval( + val pinRemoved: Boolean, + val hostNowUnpinned: Boolean +) + +private const val CERT_PIN_TAG = "CertificatePinning" + +private fun Any.readBoolProperty(name: String): Boolean { + val getter = "get" + name.replaceFirstChar { it.uppercase() } + javaClass.methods.firstOrNull { it.name == getter && it.parameterCount == 0 } + ?.let { return it.invoke(this) as Boolean } + return javaClass.getField(name).getBoolean(this) +} + fun KeepMobile.getCertificatePinsCompat(): List = runCatching { val method = javaClass.methods.firstOrNull { it.name == "getCertificatePins" } if (method == null) { @@ -21,3 +40,41 @@ fun KeepMobile.getCertificatePinsCompat(): List = runCatching { CertificatePin(hostname, spkiHash) } }.getOrDefault(emptyList()) + +/** + * Stage an additional (backup) SPKI pin for [hostname] alongside its current pins, + * so a relay certificate rotation verifies against either during the overlap + * (RFC 7469). Returns false if the method is unavailable or the call fails. + */ +fun KeepMobile.stageCertificatePinCompat(hostname: String, spkiHash: String): Boolean = runCatching { + val method = javaClass.methods.firstOrNull { + it.name == "stageCertificatePin" && it.parameterCount == 2 + } + if (method == null) { + android.util.Log.w(CERT_PIN_TAG, "stageCertificatePin method not found via reflection") + return false + } + method.invoke(this, hostname, spkiHash) + true +}.getOrDefault(false) + +/** + * Retire a single [spkiHash] from [hostname], leaving the host's other pins active + * (unlike clearing the whole host). Returns null if the method is unavailable or + * the call fails; otherwise reports whether a pin was removed and whether the host + * is now fully unpinned. + */ +fun KeepMobile.removeCertificatePinCompat(hostname: String, spkiHash: String): CertPinRemoval? = runCatching { + val method = javaClass.methods.firstOrNull { + it.name == "removeCertificatePin" && it.parameterCount == 2 + } + if (method == null) { + android.util.Log.w(CERT_PIN_TAG, "removeCertificatePin method not found via reflection") + return null + } + val result = method.invoke(this, hostname, spkiHash) ?: return null + CertPinRemoval( + pinRemoved = result.readBoolProperty("pinRemoved"), + hostNowUnpinned = result.readBoolProperty("hostNowUnpinned") + ) +}.getOrNull() diff --git a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt index 243f1c16..60eb4900 100644 --- a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt +++ b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt @@ -538,6 +538,12 @@ class KeepMobileApp : Application() { .onFailure { if (BuildConfig.DEBUG) Log.e(TAG, "Failed to clearCertificatePins: ${it::class.simpleName}") } } + fun stageCertificatePin(hostname: String, spkiHash: String): Boolean = + keepMobile?.stageCertificatePinCompat(hostname, spkiHash) ?: false + + fun removeCertificatePin(hostname: String, spkiHash: String): CertPinRemoval? = + keepMobile?.removeCertificatePinCompat(hostname, spkiHash) + fun dismissPinMismatch() { pinMismatch = null } diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index c871e968..271abcae 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -251,6 +251,10 @@ class MainActivity : FragmentActivity() { onReconnectRelays = { app.reconnectRelays() }, onClearCertificatePin = app::clearCertificatePin, onClearAllCertificatePins = app::clearAllCertificatePins, + onStageCertificatePin = app::stageCertificatePin, + onRemoveCertificatePin = { hostname, spkiHash -> + app.removeCertificatePin(hostname, spkiHash) + }, onDismissPinMismatch = app::dismissPinMismatch, onAccountSwitched = { app.onAccountSwitched() } ) @@ -296,6 +300,8 @@ fun MainScreen( onReconnectRelays: () -> Unit = {}, onClearCertificatePin: (String) -> Unit = {}, onClearAllCertificatePins: () -> Unit = {}, + onStageCertificatePin: (String, String) -> Boolean = { _, _ -> false }, + onRemoveCertificatePin: (String, String) -> Unit = { _, _ -> }, onDismissPinMismatch: () -> Unit = {}, onAccountSwitched: suspend () -> Unit = {} ) { @@ -1108,9 +1114,23 @@ fun MainScreen( profileRelays = updated coroutineScope.launch { saveProfileRelays(updated) } }, - onClearPin = { hostname -> + onStagePin = { hostname, spkiHash -> + coroutineScope.launch { + val staged = withContext(Dispatchers.IO) { + onStageCertificatePin(hostname, spkiHash) + } + refreshCertificatePins() + val message = if (staged) { + appContext.getString(R.string.settings_cert_pins_staged, hostname) + } else { + appContext.getString(R.string.settings_cert_pins_stage_failed) + } + Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show() + } + }, + onRetirePin = { hostname, spkiHash -> coroutineScope.launch { - withContext(Dispatchers.IO) { onClearCertificatePin(hostname) } + withContext(Dispatchers.IO) { onRemoveCertificatePin(hostname, spkiHash) } refreshCertificatePins() } }, @@ -1422,7 +1442,8 @@ private fun SettingsTab( onRemoveRelay: (String) -> Unit, onAddProfileRelay: (String) -> Unit, onRemoveProfileRelay: (String) -> Unit, - onClearPin: (String) -> Unit, + onStagePin: (String, String) -> Unit, + onRetirePin: (String, String) -> Unit, onClearAllPins: () -> Unit, onProxyActivate: (Int) -> Unit, onProxyDeactivate: () -> Unit, @@ -1469,7 +1490,8 @@ private fun SettingsTab( onRemoveRelay = onRemoveRelay, onAddProfileRelay = onAddProfileRelay, onRemoveProfileRelay = onRemoveProfileRelay, - onClearPin = onClearPin, + onStagePin = onStagePin, + onRetirePin = onRetirePin, onClearAllPins = onClearAllPins, onProxyActivate = onProxyActivate, onProxyDeactivate = onProxyDeactivate @@ -1768,7 +1790,8 @@ private fun ShareSettingsSection( onRemoveRelay: (String) -> Unit, onAddProfileRelay: (String) -> Unit, onRemoveProfileRelay: (String) -> Unit, - onClearPin: (String) -> Unit, + onStagePin: (String, String) -> Unit, + onRetirePin: (String, String) -> Unit, onClearAllPins: () -> Unit, onProxyActivate: (Int) -> Unit, onProxyDeactivate: () -> Unit @@ -1785,7 +1808,8 @@ private fun ShareSettingsSection( CertificatePinsCard( pins = certificatePins, - onClearPin = onClearPin, + onStagePin = onStagePin, + onRetirePin = onRetirePin, onClearAllPins = onClearAllPins ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt b/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt index a325ed21..7e04cc42 100644 --- a/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt +++ b/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt @@ -218,14 +218,24 @@ private fun SecurityLevelItem( } } +/** A host is left fully unpinned (re-opening trust-on-first-use) when retiring its only pin. */ +internal fun isLastPinForHost(pins: List, hostname: String): Boolean = + pins.count { it.hostname == hostname } <= 1 + +/** A valid SPKI pin is a SHA-256 digest: exactly 64 hex characters. */ +internal fun isValidSpkiHash(hash: String): Boolean = + hash.matches(Regex("[0-9a-fA-F]{64}")) + @Composable fun CertificatePinsCard( pins: List, - onClearPin: (String) -> Unit, + onStagePin: (String, String) -> Unit, + onRetirePin: (String, String) -> Unit, onClearAllPins: () -> Unit ) { var showClearAllDialog by remember { mutableStateOf(false) } - var pinToDelete by remember { mutableStateOf(null) } + var showStageDialog by remember { mutableStateOf(false) } + var pinToRetire by remember { mutableStateOf(null) } KeepCard(contentPadding = PaddingValues(0.dp)) { Column(modifier = Modifier.padding(16.dp)) { @@ -268,7 +278,7 @@ fun CertificatePinsCard( ) } IconButton( - onClick = { pinToDelete = pin.hostname }, + onClick = { pinToRetire = pin }, modifier = Modifier.size(24.dp) ) { Icon( @@ -280,30 +290,52 @@ fun CertificatePinsCard( } } } + TextButton(onClick = { showStageDialog = true }) { + Text(stringResource(R.string.settings_cert_pins_stage)) + } } } - pinToDelete?.let { hostname -> + pinToRetire?.let { pin -> + val lastPin = isLastPinForHost(pins, pin.hostname) AlertDialog( - onDismissRequest = { pinToDelete = null }, + onDismissRequest = { pinToRetire = null }, title = { Text(stringResource(R.string.settings_cert_pins_clear_dialog_title)) }, - text = { Text(stringResource(R.string.settings_cert_pins_clear_dialog_text, hostname)) }, + text = { + Text( + if (lastPin) { + stringResource(R.string.settings_cert_pins_clear_dialog_text, pin.hostname) + } else { + stringResource(R.string.settings_cert_pins_retire_keep_others, pin.hostname) + } + ) + }, confirmButton = { TextButton(onClick = { - onClearPin(hostname) - pinToDelete = null + onRetirePin(pin.hostname, pin.spkiHash) + pinToRetire = null }) { Text(stringResource(R.string.settings_cert_pins_clear), color = MaterialTheme.colorScheme.error) } }, dismissButton = { - TextButton(onClick = { pinToDelete = null }) { + TextButton(onClick = { pinToRetire = null }) { Text(stringResource(R.string.settings_cancel)) } } ) } + if (showStageDialog) { + StageBackupPinDialog( + onDismiss = { showStageDialog = false }, + onStage = { host, hash -> + onStagePin(host, hash) + showStageDialog = false + } + ) + } + if (showClearAllDialog) { AlertDialog( onDismissRequest = { showClearAllDialog = false }, @@ -325,3 +357,58 @@ fun CertificatePinsCard( ) } } + +@Composable +private fun StageBackupPinDialog( + onDismiss: () -> Unit, + onStage: (String, String) -> Unit +) { + var host by remember { mutableStateOf("") } + var hash by remember { mutableStateOf("") } + val hostTrimmed = host.trim() + val hashTrimmed = hash.trim() + val canStage = hostTrimmed.isNotEmpty() && isValidSpkiHash(hashTrimmed) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_cert_pins_stage_dialog_title)) }, + text = { + Column { + Text( + stringResource(R.string.settings_cert_pins_stage_dialog_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.settings_cert_pins_stage_host_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = hash, + onValueChange = { hash = it }, + label = { Text(stringResource(R.string.settings_cert_pins_stage_hash_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onStage(hostTrimmed, hashTrimmed) }, + enabled = canStage + ) { + Text(stringResource(R.string.settings_cert_pins_stage_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + } + ) +} diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index f55a6e92..886a9e11 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -97,6 +97,15 @@ Clear Clear All Pins? This will remove all stored certificate pins. New pins will be set on next connection. + Remove this pin for %1$s? The host\'s other pins stay active. + Stage backup pin + Stage Backup Pin + Add a backup certificate pin for a relay so a key rotation verifies against the current or the next certificate during the overlap. + Relay host + SPKI SHA-256 (64 hex) + Stage + Backup pin staged for %1$s + Could not stage pin. Check the host and 64-character hex hash. Security diff --git a/app/src/test/kotlin/io/privkey/keep/CertificatePinsCardTest.kt b/app/src/test/kotlin/io/privkey/keep/CertificatePinsCardTest.kt new file mode 100644 index 00000000..39d1658a --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/CertificatePinsCardTest.kt @@ -0,0 +1,57 @@ +package io.privkey.keep + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Retiring a pin must warn the operator only when it leaves the host unpinned + * (re-opening trust-on-first-use), and staging must reject anything that is not + * a 32-byte SHA-256 SPKI digest before it reaches keep-mobile. + */ +class CertificatePinsCardTest { + private val hostA = "relay.example.com" + private val hostB = "relay.other.com" + private val hashOne = "a".repeat(64) + private val hashTwo = "b".repeat(64) + + @Test + fun singlePinForHost_isLastPin() { + val pins = listOf(CertificatePin(hostA, hashOne)) + assertTrue(isLastPinForHost(pins, hostA)) + } + + @Test + fun multiplePinsForHost_isNotLastPin() { + val pins = listOf(CertificatePin(hostA, hashOne), CertificatePin(hostA, hashTwo)) + assertFalse(isLastPinForHost(pins, hostA)) + } + + @Test + fun countsOnlyMatchingHost() { + val pins = listOf(CertificatePin(hostA, hashOne), CertificatePin(hostB, hashTwo)) + assertTrue(isLastPinForHost(pins, hostA)) + assertTrue(isLastPinForHost(pins, hostB)) + } + + @Test + fun unknownHost_isLastPin() { + assertTrue(isLastPinForHost(emptyList(), hostA)) + } + + @Test + fun validSpkiHash_is64Hex() { + assertTrue(isValidSpkiHash("0".repeat(64))) + assertTrue(isValidSpkiHash("aAbBcCdDeEfF" + "0".repeat(52))) + } + + @Test + fun invalidSpkiHash_rejected() { + assertFalse(isValidSpkiHash("")) + assertFalse(isValidSpkiHash("a".repeat(63))) + assertFalse(isValidSpkiHash("a".repeat(65))) + assertFalse(isValidSpkiHash("g".repeat(64))) + assertFalse(isValidSpkiHash("z".repeat(64))) + assertFalse(isValidSpkiHash(" " + "a".repeat(64))) + } +} diff --git a/keep.version b/keep.version index 340b738c..bc279c07 100644 --- a/keep.version +++ b/keep.version @@ -1 +1 @@ -5e735dbdbae3dc999d22c6a21b28c063948244cd \ No newline at end of file +fe8f8d56f238eea6d6b901552c90837225c5274f \ No newline at end of file From 773a5b97c5f9a59483093fc33ff0107c2ac7ceae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 15 Jul 2026 13:33:43 -0400 Subject: [PATCH 2/2] keep-android: strengthen cert-pin unpinning warnings and consume host_now_unpinned --- app/src/main/kotlin/io/privkey/keep/MainActivity.kt | 13 +++++++++++-- .../main/kotlin/io/privkey/keep/SecurityCards.kt | 2 +- app/src/main/res/values/strings_settings.xml | 4 +++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 271abcae..d73d8aa2 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -301,7 +301,7 @@ fun MainScreen( onClearCertificatePin: (String) -> Unit = {}, onClearAllCertificatePins: () -> Unit = {}, onStageCertificatePin: (String, String) -> Boolean = { _, _ -> false }, - onRemoveCertificatePin: (String, String) -> Unit = { _, _ -> }, + onRemoveCertificatePin: (String, String) -> CertPinRemoval? = { _, _ -> null }, onDismissPinMismatch: () -> Unit = {}, onAccountSwitched: suspend () -> Unit = {} ) { @@ -1130,8 +1130,17 @@ fun MainScreen( }, onRetirePin = { hostname, spkiHash -> coroutineScope.launch { - withContext(Dispatchers.IO) { onRemoveCertificatePin(hostname, spkiHash) } + val removal = withContext(Dispatchers.IO) { + onRemoveCertificatePin(hostname, spkiHash) + } refreshCertificatePins() + if (removal?.hostNowUnpinned == true) { + Toast.makeText( + appContext, + appContext.getString(R.string.settings_cert_pins_unpinned_warning, hostname), + Toast.LENGTH_LONG + ).show() + } } }, onClearAllPins = { diff --git a/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt b/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt index 7e04cc42..f18096ab 100644 --- a/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt +++ b/app/src/main/kotlin/io/privkey/keep/SecurityCards.kt @@ -304,7 +304,7 @@ fun CertificatePinsCard( text = { Text( if (lastPin) { - stringResource(R.string.settings_cert_pins_clear_dialog_text, pin.hostname) + stringResource(R.string.settings_cert_pins_retire_last_dialog_text, pin.hostname) } else { stringResource(R.string.settings_cert_pins_retire_keep_others, pin.hostname) } diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 886a9e11..52712c9d 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -98,9 +98,11 @@ Clear All Pins? This will remove all stored certificate pins. New pins will be set on next connection. Remove this pin for %1$s? The host\'s other pins stay active. + This is the only pin for %1$s. Removing it leaves the relay unpinned, so the next connection trusts whatever certificate is presented (including an attacker\'s). Only continue if you intend to reset this relay\'s pin. + %1$s is now unpinned; the next connection sets a new pin. Stage backup pin Stage Backup Pin - Add a backup certificate pin for a relay so a key rotation verifies against the current or the next certificate during the overlap. + Add a backup certificate pin so a relay key rotation verifies against the current or the next certificate during the overlap. Any certificate matching a pin is fully trusted: only enter a hash you obtained from the relay operator over a trusted channel. Relay host SPKI SHA-256 (64 hex) Stage