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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/CertificatePinning.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<CertificatePin> = runCatching {
val method = javaClass.methods.firstOrNull { it.name == "getCertificatePins" }
if (method == null) {
Expand All @@ -21,3 +40,41 @@ fun KeepMobile.getCertificatePinsCompat(): List<CertificatePin> = 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()
6 changes: 6 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 39 additions & 6 deletions app/src/main/kotlin/io/privkey/keep/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
)
Expand Down Expand Up @@ -296,6 +300,8 @@ fun MainScreen(
onReconnectRelays: () -> Unit = {},
onClearCertificatePin: (String) -> Unit = {},
onClearAllCertificatePins: () -> Unit = {},
onStageCertificatePin: (String, String) -> Boolean = { _, _ -> false },
onRemoveCertificatePin: (String, String) -> CertPinRemoval? = { _, _ -> null },
onDismissPinMismatch: () -> Unit = {},
onAccountSwitched: suspend () -> Unit = {}
) {
Expand Down Expand Up @@ -1108,10 +1114,33 @@ 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) }
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 = {
Expand Down Expand Up @@ -1422,7 +1451,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,
Expand Down Expand Up @@ -1469,7 +1499,8 @@ private fun SettingsTab(
onRemoveRelay = onRemoveRelay,
onAddProfileRelay = onAddProfileRelay,
onRemoveProfileRelay = onRemoveProfileRelay,
onClearPin = onClearPin,
onStagePin = onStagePin,
onRetirePin = onRetirePin,
onClearAllPins = onClearAllPins,
onProxyActivate = onProxyActivate,
onProxyDeactivate = onProxyDeactivate
Expand Down Expand Up @@ -1768,7 +1799,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
Expand All @@ -1785,7 +1817,8 @@ private fun ShareSettingsSection(

CertificatePinsCard(
pins = certificatePins,
onClearPin = onClearPin,
onStagePin = onStagePin,
onRetirePin = onRetirePin,
onClearAllPins = onClearAllPins
)
Spacer(modifier = Modifier.height(16.dp))
Expand Down
105 changes: 96 additions & 9 deletions app/src/main/kotlin/io/privkey/keep/SecurityCards.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<CertificatePin>, 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<CertificatePin>,
onClearPin: (String) -> Unit,
onStagePin: (String, String) -> Unit,
onRetirePin: (String, String) -> Unit,
onClearAllPins: () -> Unit
) {
var showClearAllDialog by remember { mutableStateOf(false) }
var pinToDelete by remember { mutableStateOf<String?>(null) }
var showStageDialog by remember { mutableStateOf(false) }
var pinToRetire by remember { mutableStateOf<CertificatePin?>(null) }

KeepCard(contentPadding = PaddingValues(0.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Expand Down Expand Up @@ -268,7 +278,7 @@ fun CertificatePinsCard(
)
}
IconButton(
onClick = { pinToDelete = pin.hostname },
onClick = { pinToRetire = pin },
modifier = Modifier.size(24.dp)
) {
Icon(
Expand All @@ -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_retire_last_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 },
Expand All @@ -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))
}
}
)
}
11 changes: 11 additions & 0 deletions app/src/main/res/values/strings_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@
<string name="settings_cert_pins_clear">Clear</string>
<string name="settings_cert_pins_clear_all_dialog_title">Clear All Pins?</string>
<string name="settings_cert_pins_clear_all_dialog_text">This will remove all stored certificate pins. New pins will be set on next connection.</string>
<string name="settings_cert_pins_retire_keep_others">Remove this pin for <xliff:g id="hostname" example="relay.example.com">%1$s</xliff:g>? The host\'s other pins stay active.</string>
<string name="settings_cert_pins_retire_last_dialog_text">This is the only pin for <xliff:g id="hostname" example="relay.example.com">%1$s</xliff:g>. 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.</string>
<string name="settings_cert_pins_unpinned_warning"><xliff:g id="hostname" example="relay.example.com">%1$s</xliff:g> is now unpinned; the next connection sets a new pin.</string>
<string name="settings_cert_pins_stage">Stage backup pin</string>
<string name="settings_cert_pins_stage_dialog_title">Stage Backup Pin</string>
<string name="settings_cert_pins_stage_dialog_text">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.</string>
<string name="settings_cert_pins_stage_host_label">Relay host</string>
<string name="settings_cert_pins_stage_hash_label">SPKI SHA-256 (64 hex)</string>
<string name="settings_cert_pins_stage_confirm">Stage</string>
<string name="settings_cert_pins_staged">Backup pin staged for <xliff:g id="hostname" example="relay.example.com">%1$s</xliff:g></string>
<string name="settings_cert_pins_stage_failed">Could not stage pin. Check the host and 64-character hex hash.</string>

<!-- SecuritySettingsScreen -->
<string name="settings_security_screen_title">Security</string>
Expand Down
Loading