Skip to content

Commit

Permalink
Implement pkcs12 handling
Browse files Browse the repository at this point in the history
  • Loading branch information
hufman committed Aug 27, 2024
1 parent e77310e commit 3d936bb
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 1 deletion.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0'

testImplementation 'junit:junit:4.12'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'org.awaitility:awaitility-scala:3.1.5'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
implementation "org.bouncycastle:bcmail-jdk16:1.46"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.x509.CertificateList
import org.bouncycastle.cert.X509CRLHolder
import org.bouncycastle.cert.X509CertificateHolder
import org.bouncycastle.cms.CMSAbsentContent
import org.bouncycastle.cms.CMSSignedData
import org.bouncycastle.cms.CMSSignedDataGenerator
import org.bouncycastle.openssl.PEMReader
import org.bouncycastle.openssl.PEMWriter
import org.bouncycastle.util.CollectionStore
Expand All @@ -15,6 +17,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.security.cert.Certificate
import java.util.*

/**
Expand Down Expand Up @@ -76,6 +79,20 @@ object CertMangling {
return pem.toByteArray()
}

/**
* Outputs the given List<Certificate> as a p7b PEM string
*/
fun outputCert(certs: List<Certificate>): ByteArray {
val bcCerts = certs.map {
X509CertificateHolder(it.encoded)
}
val certStore = CollectionStore(bcCerts)
val generator = CMSSignedDataGenerator()
generator.addCertificates(certStore)
val certCMS = generator.generate(CMSAbsentContent())
return outputCert(certCMS)
}

/**
* Given a p7b PEM cert obtained from a CarAPI app, and also
* given the BMW p7b PEM cert loaded from a SecurityService
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package io.bimmergestalt.idriveconnectkit.android.security

import org.bouncycastle.util.encoders.Base64
import java.io.IOException
import java.io.InputStream
import java.security.KeyStore
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.Signature
import java.security.UnrecoverableKeyException
import java.security.cert.Certificate
import kotlin.experimental.xor
import kotlin.jvm.Throws

object PrivateKeyHandling {
/**
* Given a token and a package name from a BMW certificate
* calculate the passphrase for the private pkcs12 file
* For iOS certs, the token is found in the cert's plist
* and the packageName is in the Info.plist
* Throws IllegalArgumentException for errors decoding
*/
@Throws(IllegalArgumentException::class)
fun decodePassphrase(token: String, packageName: String): String {
val tokenArray = Base64.decode(token)
val decoded = StringBuffer(tokenArray.size / 4)

if (tokenArray.size < 5) {
throw IllegalArgumentException("Token is too small")
}

var newChar = tokenArray[0]
for (i in 4 ..< tokenArray.size-3 step 4) {
if (tokenArray[i+1] != 0.toByte()) {
throw IllegalArgumentException("High byte found at ${i+1}")
}
if (tokenArray[i+3] != 0.toByte()) {
throw IllegalArgumentException("High byte found at ${i+3}")
}

val nameIndex = newChar xor tokenArray[2] xor tokenArray[i]
if (nameIndex < 0 || nameIndex >= packageName.length) {
throw IllegalArgumentException("Out of bounds $nameIndex when decoding token at $i")
}

newChar = newChar xor tokenArray[i+2] xor 0x17 xor packageName[nameIndex.toInt()].code.toByte()
decoded.append(newChar.toInt().toChar())
}

return decoded.toString()
}

/**
* Opens the given pkcs12 file, unlocking with the given passphrase
* and returns the private key within
* Returns null if no private key was found
* Throws IllegalArgumentException for invalid passphrase
*/
@Throws(IllegalArgumentException::class)
fun loadPrivateKey(pkcs12: InputStream, passphrase: String): PrivateKey? {
val password = passphrase.toCharArray()
val keystore = KeyStore.getInstance("PKCS12")
try {
keystore.load(pkcs12, password)
} catch (e: IOException) {
throw IllegalArgumentException("Incorrect keystore password", e)
}

for (entryName in keystore.aliases()) {
val key = try {
keystore.getKey(entryName, password)
} catch (e: UnrecoverableKeyException) {
throw IllegalArgumentException("Incorrect key password", e)
}
if (key is PrivateKey) {
return key
}
}
return null
}

/**
* Returns the public certs from the given pkcs12 file
* Can be sent through CertMangling.outputCert() to create a p7b file for the car
*/
@Throws(IllegalArgumentException::class)
fun loadPublicCerts(pkcs12: InputStream, passphrase: String): ArrayList<Certificate> {
val password = passphrase.toCharArray()
val keystore = KeyStore.getInstance("PKCS12")
try {
keystore.load(pkcs12, password)
} catch (e: IOException) {
throw IllegalArgumentException("Incorrect keystore password", e)
}

val certs = ArrayList<Certificate>()
for (entryName in keystore.aliases()) {
val cert = keystore.getCertificate(entryName)
if (cert is Certificate) {
certs.add(cert)
}
}
return certs
}

/**
* After sending a cert to the car, the car will provide a challenge
* to prove the possession of the cert's private key
* This function generates the expected response
* Legacy certs should specify applyXor=false, while
* newer certs with SAS.CertificateType=APP_AUTH should say applyXor=true
*/
fun signChallenge(key: PrivateKey, challenge: ByteArray, applyXor: Boolean): ByteArray {
val message = if (!applyXor) {
challenge
} else {
val hasher = MessageDigest.getInstance("MD5")
hasher.update(byteArrayOf(0x02, 0x00, 0x00, 0x00), 0, 4)
val halfway = hasher.digest()
val output = ByteArray(challenge.size)
challenge.forEachIndexed { index, byte ->
output[index] = byte xor halfway[index % halfway.size]
}
output
}

val sign = Signature.getInstance("MD5withRSA")
sign.initSign(key)
sign.update(message)
return sign.sign()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.bimmergestalt.idriveconnectkit.android.security

import org.junit.Test

import org.junit.Assert.*
import java.nio.charset.Charset

class PrivateKeyHandlingTest {

val TOKEN = "AAABAAsASgB+AF4AeQAmADEAJAAgAGoAaQByAGwAJAArAAcAUQAkAEcAEAA3AG8A" +
"JwAFAFoAVgBpAGIAZgBhAGYAbQBlAHoALABNAEQAMAA/ACYAbgBGAFQASABfAEAA" +
"RgAKADAAdAApAHAAOgBWAF8AfwB5AGkAcAAEAFAAYwBaAGAARgBFAGMAbwArAE0A" +
"OgBeAEcAAgA0ACIAKwBeAEAAYgA="

@Test
fun decodePassphrase() {
val decoded = PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a.BMWAppKit")
assertEquals(40, decoded.length)
assertTrue(decoded.startsWith("sw6+xm:ZG"))
}

@Test
fun decodePassphraseFail() {
assertThrows(IllegalArgumentException::class.java) {
PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a")
}
}

@Test
fun loadPrivateKey() {
val pkcs12 = this.javaClass.classLoader!!.getResourceAsStream("BMWAppKitDevelopment.p12")
val passphrase = PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a.BMWAppKit")
val key = PrivateKeyHandling.loadPrivateKey(pkcs12, passphrase)
assertNotNull(key)
}

@Test
fun loadPrivateKeyFail() {
val pkcs12 = this.javaClass.classLoader!!.getResourceAsStream("BMWAppKitDevelopment.p12")
val passphrase = PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a.BMWAppKit")
assertThrows(IllegalArgumentException::class.java) {
PrivateKeyHandling.loadPrivateKey(pkcs12, passphrase.substring(0..<10))
}
}

@Test
fun loadPublicCert() {
val pkcs12 = this.javaClass.classLoader!!.getResourceAsStream("BMWAppKitDevelopment.p12")
val passphrase = PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a.BMWAppKit")
val certs = PrivateKeyHandling.loadPublicCerts(pkcs12, passphrase)
assertEquals(1, certs.size)

val pem = CertMangling.outputCert(certs)
println(pem.toString(Charset.defaultCharset()))
val parsed = CertMangling.loadCerts(pem)
assertEquals(1, parsed?.size)
}

@Test
fun signChallenge() {
// the pkcs12 from BMW Connected 10.4 for iOS
// different versions have different keys and different responses
val pkcs12 = this.javaClass.classLoader!!.getResourceAsStream("BMWAppKitDevelopment.p12")
val passphrase = PrivateKeyHandling.decodePassphrase(TOKEN, "de.bmw.a4a.BMWAppKit")
val key = PrivateKeyHandling.loadPrivateKey(pkcs12, passphrase)!!

val challenge = byteArrayOf(
0x6d, 0x58, 0x5f, 0x14,
0x72, 0x72, 0x19, 0x75,
0x4e, 0x73, 0x19, 0x38,
0x61, 0x2f, 0x50, 0x78)
val response = PrivateKeyHandling.signChallenge(key, challenge, true)
assertEquals(192, response.size)
// print(response.toHexString())
assertEquals(0x4d.toByte(), response[0])
assertEquals(0x0e.toByte(), response[1])
assertEquals(0x33.toByte(), response[2])

val response2 = PrivateKeyHandling.signChallenge(key, challenge, false)
assertEquals(192, response.size)
// print(response.toHexString())
assertEquals(0xc2.toByte(), response2[0])
assertEquals(0x59.toByte(), response2[1])
assertEquals(0xc5.toByte(), response2[2])
}
}
Binary file added src/test/resources/BMWAppKitDevelopment.p12
Binary file not shown.

0 comments on commit 3d936bb

Please sign in to comment.