A certificate can be downloaded from any link and converted, and fetched again later

A .p12, with or without a password, or a certificate and key as PEM or DER, in one file or two.
Gearslip ships no address of its own; the driver pastes one, and it's kept for Download again.
This commit is contained in:
Seb3thehacker committed 2026-11-08 21:52:24 -08:00
1 parent 85c8133442
commit ba86685935
21 files changed
+562

No files matched your search

@@ -0,0 +1,175 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.KeyFactory
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
/**
* A certificate fetched from a link the driver pastes (issue #15), in whatever form it comes:
* a PKCS#12 file, with or without a password, or a certificate and its private key as PEM or
* DER, together in one file or in two. Whatever arrives is converted to the PKCS#12 identity
* Gearslip presents to the car, and stored as an imported certificate.
*
* Gearslip ships no address of its own. The links are kept on the phone so the same files can
* be fetched again from Settings, and they're only ever fetched when the driver asks.
*/
object CertFromLink {
sealed interface Outcome {
/** Imported, and in use from the next connection. */
data object Done : Outcome
/** A PKCS#12 file that needs its password. */
data object NeedsPassword : Outcome
/** A certificate on its own: its private key is at another address. */
data object NeedsKey : Outcome
}
private const val KEY_CERT_LINK = "cert_link"
private const val KEY_KEY_LINK = "cert_key_link"
private const val MAX_BYTES = 1L * 1024 * 1024
/** The password the converted file is stored under; it never leaves the app's storage. */
private const val STORE_PASSWORD = "gearslip"
/** The link used last, for Download again, or null when none was ever used. */
fun savedLink(context: Context): Pair<String, String?>? {
val cert = AppSettings.getString(context, KEY_CERT_LINK, "").ifEmpty { return null }
return cert to AppSettings.getString(context, KEY_KEY_LINK, "").ifEmpty { null }
}
/** Runs on a background thread. Throws with a message a driver can read when it can't be used. */
fun import(context: Context, certLink: String, keyLink: String?, password: String?): Outcome {
val first = download(certLink)
// The key's link is fetched only when the first file turns out to hold no key.
var second: ByteArray? = null
var converted = convert(first, null, password)
if (converted is Converted.NeedsKey && !keyLink.isNullOrBlank()) {
second = download(keyLink)
converted = convert(first, second, password)
}
return when (converted) {
Converted.NeedsKey -> Outcome.NeedsKey
Converted.NeedsPassword -> Outcome.NeedsPassword
is Converted.Ready -> {
CertProvider.importFromBytes(context, converted.p12, converted.password)
remember(context, certLink, keyLink.takeIf { second != null })
Outcome.Done
}
}
}
internal sealed interface Converted {
class Ready(val p12: ByteArray, val password: String) : Converted
data object NeedsPassword : Converted
data object NeedsKey : Converted
}
/**
* Turns what was downloaded into a PKCS#12 file with its password. [first] is the file at
* the first link; [second] the key from the second, once asked for.
*/
internal fun convert(first: ByteArray, second: ByteArray?, password: String?): Converted {
// A PKCS#12 file is DER too, so the certificate parser gets the first try.
val certs = certificates(first)
if (certs.isEmpty()) {
val pass = password.orEmpty()
val loaded = runCatching { KeyStore.getInstance("PKCS12").apply { load(ByteArrayInputStream(first), pass.toCharArray()) } }
if (loaded.isFailure) {
if (password == null) return Converted.NeedsPassword
error("That password doesn't open the file, or it isn't a certificate.")
}
val store = loaded.getOrThrow()
require(store.aliases().asSequence().any { store.isKeyEntry(it) }) { "The file holds no private key, so the car can't be shown it." }
return Converted.Ready(first, pass)
}
val key = privateKey(first) ?: second?.let {
privateKey(it) ?: error("The second link isn't a private key in a form Gearslip reads.")
} ?: return Converted.NeedsKey
// The certificate that goes with the key comes first, and any issuers after it.
val leaf = certs.firstOrNull { belongs(key, it) } ?: error("The private key doesn't belong to this certificate.")
val chain = listOf(leaf) + certs.filter { it !== leaf }
val store = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry("phone", key, STORE_PASSWORD.toCharArray(), chain.toTypedArray())
}
val bytes = ByteArrayOutputStream().use { store.store(it, STORE_PASSWORD.toCharArray()); it.toByteArray() }
GearslipLog.i("certificate from a link: ${leaf.subjectX500Principal}")
return Converted.Ready(bytes, STORE_PASSWORD)
}
private fun remember(context: Context, cert: String, key: String?) {
AppSettings.putString(context, KEY_CERT_LINK, cert)
AppSettings.putString(context, KEY_KEY_LINK, key.orEmpty())
}
private fun download(link: String): ByteArray {
val url = link.trim()
require(url.startsWith("https://") || url.startsWith("http://")) { "That isn't a web address." }
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
instanceFollowRedirects = true
}
try {
if (connection.responseCode !in 200..299) error("The server answered ${connection.responseCode}.")
val out = ByteArrayOutputStream()
connection.inputStream.use { input ->
val buffer = ByteArray(16 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
require(out.size() <= MAX_BYTES) { "That file is far too big to be a certificate." }
}
}
return out.toByteArray()
} finally {
connection.disconnect()
}
}
/** Every certificate in PEM text, or the one certificate a DER file holds. */
private fun certificates(bytes: ByteArray): List<X509Certificate> {
val factory = CertificateFactory.getInstance("X.509")
val pem = pemBlocks(bytes, "CERTIFICATE")
if (pem.isNotEmpty()) return pem.map { factory.generateCertificate(ByteArrayInputStream(it)) as X509Certificate }
return listOfNotNull(runCatching { factory.generateCertificate(ByteArrayInputStream(bytes)) as X509Certificate }.getOrNull())
}
/** A PKCS#8 or PKCS#1 key, as PEM or as PKCS#8 DER; null when there's none. */
private fun privateKey(bytes: ByteArray): PrivateKey? {
require(pemBlocks(bytes, "ENCRYPTED PRIVATE KEY").isEmpty()) { "The private key is encrypted. Gearslip needs one without a passphrase, or a .p12 file." }
pemBlocks(bytes, "PRIVATE KEY").firstOrNull()?.let { return pkcs8(it) }
if (pemBlocks(bytes, "RSA PRIVATE KEY").isNotEmpty()) return PemKeyParser.parsePrivateKey(String(bytes)).getPrivate()
if (String(bytes).contains("-----BEGIN")) return null
return runCatching { pkcs8(bytes) }.getOrNull()
}
private fun pkcs8(der: ByteArray): PrivateKey = runCatching {
KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der))
}.getOrElse { KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(der)) }
/**
* Whether [key] is [cert]'s. RSA keys are matched by modulus, which every Android provider
* exposes, even one that hides the rest of a PKCS#8 key. Any other kind is taken on trust.
*/
private fun belongs(key: PrivateKey, cert: X509Certificate): Boolean {
val public = cert.publicKey
return if (key is java.security.interfaces.RSAKey && public is java.security.interfaces.RSAKey) key.modulus == public.modulus else key.algorithm == public.algorithm
}
private fun pemBlocks(bytes: ByteArray, type: String): List<ByteArray> {
val text = runCatching { String(bytes, Charsets.US_ASCII) }.getOrDefault("")
return Regex("-----BEGIN $type-----([A-Za-z0-9+/=\\s]+)-----END $type-----").findAll(text)
.map { Base64.getMimeDecoder().decode(it.groupValues[1]) }.toList()
}
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.material.icons.filled.Create
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R
@@ -356,6 +358,8 @@ private fun CertificateSection() {
var notice by remember { mutableStateOf<String?>(null) }
var downloading by remember { mutableStateOf(false) }
var detailsOpen by remember { mutableStateOf(false) }
var linkDialog by remember { mutableStateOf<LinkRequest?>(null) }
val savedLink = remember(refresh) { app.seb3thehacker.gearslip.CertFromLink.savedLink(context) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
@@ -413,6 +417,22 @@ private fun CertificateSection() {
icon = Icons.Filled.Refresh,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_link),
Modifier.clickable { linkDialog = LinkRequest() },
subtitle = stringResource(R.string.cert_link_sub),
icon = Icons.Filled.Create,
)
savedLink?.let { (cert, key) ->
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_link_again),
Modifier.clickable { linkDialog = LinkRequest(cert, key, start = true) },
subtitle = cert.substringAfter("://").substringBefore('/'),
icon = Icons.Filled.Refresh,
)
}
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_import),
Modifier.clickable { picker.launch(arrayOf("*/*")) },
@@ -440,6 +460,14 @@ private fun CertificateSection() {
}
}
linkDialog?.let { request ->
LinkDialog(request, onDismiss = { linkDialog = null }) {
linkDialog = null
notice = context.getString(R.string.cert_link_done)
refresh++
}
}
pendingUri?.let { uri ->
PasswordDialog(
onDismiss = { pendingUri = null },
@@ -461,6 +489,75 @@ private fun CertificateSection() {
}
}
/** What the link dialog opens with: blank, or the saved link to fetch again straight away. */
private class LinkRequest(val cert: String = "https://", val key: String? = null, val start: Boolean = false)
/**
* Fetches a certificate from a link (issue #15). It asks for more only when the file needs it:
* the password of a .p12, or the link to a key that came separately.
*/
@Composable
private fun LinkDialog(request: LinkRequest, onDismiss: () -> Unit, onDone: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cert by remember { mutableStateOf(request.cert) }
var key by remember { mutableStateOf(request.key) }
var password by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun go() {
busy = true
error = null
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { app.seb3thehacker.gearslip.CertFromLink.import(context, cert, key?.takeIf { it.isNotBlank() }, password) }
}
busy = false
result.onSuccess { outcome ->
when (outcome) {
app.seb3thehacker.gearslip.CertFromLink.Outcome.Done -> onDone()
app.seb3thehacker.gearslip.CertFromLink.Outcome.NeedsKey -> if (key == null) key = "https://" else error = context.getString(R.string.cert_link_key_note)
app.seb3thehacker.gearslip.CertFromLink.Outcome.NeedsPassword -> if (password == null) password = "" else error = context.getString(R.string.cert_import_failed)
}
}.onFailure { error = context.getString(R.string.cert_link_failed, it.message.orEmpty()) }
}
}
LaunchedEffect(request) { if (request.start) go() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.cert_link_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.cert_link_note))
OutlinedTextField(
cert, { cert = it }, label = { Text(stringResource(R.string.cert_link_label)) }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
key?.let { k ->
Text(stringResource(R.string.cert_link_key_note), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
k, { key = it }, label = { Text(stringResource(R.string.cert_link_key)) }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
password?.let { p ->
Text(stringResource(R.string.cert_link_password_note), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
p, { password = it }, label = { Text(stringResource(R.string.cert_link_password)) }, singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
}
},
confirmButton = {
TextButton(::go, enabled = !busy) { Text(stringResource(if (busy) R.string.cert_downloading else R.string.cert_link_download)) }
},
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@Composable
private fun Detail(label: String, value: String) {
Column {