Skip to content

Commit

Permalink
Create self-signed certificate under iOS
Browse files Browse the repository at this point in the history
This implementas a rudimentary DER encoder for X.509 certificates
  • Loading branch information
nodh committed Sep 20, 2023
1 parent d6a3fd5 commit fa7b80b
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import at.asitplus.wallet.lib.agent.VerifierAgent
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.iso.IsoDataModelConstants
import com.benasher44.uuid.uuid4
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldBeSingleton
import io.kotest.matchers.collections.shouldHaveSingleElement
import io.kotest.matchers.collections.shouldMatchEach
import io.kotest.matchers.collections.shouldNotBeEmpty
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.runBlocking
Expand All @@ -34,6 +35,8 @@ class OidcSiopIsoProtocolTest : FreeSpec({
lateinit var verifierSiop: OidcSiopVerifier

beforeEach {
Napier.takeLogarithm()
Napier.base(DebugAntilog())
holderCryptoService = DefaultCryptoService()
verifierCryptoService = DefaultCryptoService()
relyingPartyUrl = "https://example.com/rp/${uuid4()}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ object JwsExtensions {
fun Int.encodeToByteArray(): ByteArray =
byteArrayOf((this ushr 24).toByte(), (this ushr 16).toByte(), (this ushr 8).toByte(), (this).toByte())

/**
* Encode as a four-byte array
*/
fun Long.encodeToByteArray(): ByteArray =
byteArrayOf(
(this ushr 56).toByte(), (this ushr 48).toByte(), (this ushr 40).toByte(), (this ushr 32).toByte(),
(this ushr 24).toByte(), (this ushr 16).toByte(), (this ushr 8).toByte(), (this).toByte()
)

/**
* Strips the leading 0x00 byte of an ASN.1-encoded Integer,
* that will be there if the first bit of the value is set,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package at.asitplus.wallet.lib.jws

import at.asitplus.wallet.lib.CryptoPublicKey
import at.asitplus.wallet.lib.jws.JwsExtensions.encodeToByteArray
import io.matthewnelson.encoding.base16.Base16
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
import kotlinx.datetime.Instant

data class TbsCertificate(
val version: Int = 2,
val serialNumber: Long,
val signatureAlgorithm: JwsAlgorithm,
val issuer: String,
val validFrom: Instant,
val validUntil: Instant,
val subject: String,
val subjectPublicKey: CryptoPublicKey
) {
fun encodeToDer(): ByteArray {
return (version.encodeAsVersion() +
serialNumber.encodeToDer() +
signatureAlgorithm.encodeToDer() +
issuer.encodeAsCommonName() +
(validFrom.encodeToDer() + validUntil.encodeToDer()).sequence() +
subject.encodeAsCommonName() +
subjectPublicKey.encodeToDer())
.sequence()
}
}

data class X509Certificate(
val tbsCertificate: TbsCertificate,
val signatureAlgorithm: JwsAlgorithm,
val signature: ByteArray
) {
fun encodeToDer(): ByteArray {
return (tbsCertificate.encodeToDer() +
signatureAlgorithm.encodeToDer() +
signature.encodeAsBitString()).sequence()
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false

other as X509Certificate

if (tbsCertificate != other.tbsCertificate) return false
if (signatureAlgorithm != other.signatureAlgorithm) return false
if (!signature.contentEquals(other.signature)) return false

return true
}

override fun hashCode(): Int {
var result = tbsCertificate.hashCode()
result = 31 * result + signatureAlgorithm.hashCode()
result = 31 * result + signature.contentHashCode()
return result
}
}

private fun String.encodeAsCommonName(): ByteArray {
return ("550403".decodeToByteArray(Base16()).oid() + this.encodeToDer()).sequence().set().sequence()
}

private fun Int.encodeAsVersion(): ByteArray = encodeToDer().wrapInAsn1Tag(0xA0.toByte())

private fun Int.encodeToDer(): ByteArray =
encodeToByteArray().dropWhile { it == 0.toByte() }.toByteArray().wrapInAsn1Tag(0x02)

private fun Long.encodeToDer(): ByteArray =
encodeToByteArray().dropWhile { it == 0.toByte() }.toByteArray().wrapInAsn1Tag(0x02)

private fun CryptoPublicKey.encodeToDer(): ByteArray = when (this) {
is CryptoPublicKey.Ec -> this.encodeToDer()
}

private fun CryptoPublicKey.Ec.encodeToDer(): ByteArray {
val ecKeyTag = "2A8648CE3D0201".decodeToByteArray(Base16()).oid()
val ecEncryptionNullTag = "2A8648CE3D030107".decodeToByteArray(Base16()).oid()
val content = (byteArrayOf(0x04.toByte()) + x + y).encodeAsBitString()
return ((ecKeyTag + ecEncryptionNullTag).sequence() + content).sequence()
}

private fun ByteArray.encodeAsBitString(): ByteArray = (byteArrayOf(0x00) + this).wrapInAsn1Tag(0x03)

private fun String.encodeToDer(): ByteArray = this.encodeToByteArray().wrapInAsn1Tag(0x0c)

private fun Instant.encodeToDer(): ByteArray {
val matchResult =
Regex("[0-9]{2}([0-9]{2})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})\\.([0-9]+)Z")
.matchEntire(toString())
?: throw IllegalArgumentException("instant serialization failed")
val year = matchResult.groups[1]?.value ?: throw IllegalArgumentException("instant serialization failed")
val month = matchResult.groups[2]?.value ?: throw IllegalArgumentException("instant serialization failed")
val day = matchResult.groups[3]?.value ?: throw IllegalArgumentException("instant serialization failed")
val hour = matchResult.groups[4]?.value ?: throw IllegalArgumentException("instant serialization failed")
val minute = matchResult.groups[5]?.value ?: throw IllegalArgumentException("instant serialization failed")
val seconds = matchResult.groups[6]?.value ?: throw IllegalArgumentException("instant serialization failed")
val string = "$year$month$day$hour$minute${seconds}Z"
return string.encodeToByteArray().wrapInAsn1Tag(0x17)
}

private fun JwsAlgorithm.encodeToDer(): ByteArray {
return when (this) {
JwsAlgorithm.ES256 -> "2A8648CE3D040302".decodeToByteArray(Base16()).oid().sequence()
else -> TODO()
}
}

private fun ByteArray.sequence() = this.wrapInAsn1Tag(0x30)

private fun ByteArray.set() = this.wrapInAsn1Tag(0x31)

private fun ByteArray.oid() = this.wrapInAsn1Tag(0x06)

private fun ByteArray.wrapInAsn1Tag(tag: Byte): ByteArray {
return byteArrayOf(tag) + this.size.encodeLength() + this
}

private fun Int.encodeLength(): ByteArray {
if (this < 128) {
return byteArrayOf(this.toByte())
}
if (this < 0x100) {
return byteArrayOf(0x81.toByte(), this.toByte())
}
if (this < 0x8000) {
return byteArrayOf(0x82.toByte(), (this ushr 8).toByte(), this.toByte())
}
throw IllegalArgumentException("length $this")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import at.asitplus.KmmResult
import at.asitplus.wallet.lib.CryptoPublicKey
import at.asitplus.wallet.lib.cbor.CoseAlgorithm
import at.asitplus.wallet.lib.data.Base64Strict
import at.asitplus.wallet.lib.jws.EcCurve
import at.asitplus.wallet.lib.jws.JsonWebKey
import at.asitplus.wallet.lib.jws.JweAlgorithm
import at.asitplus.wallet.lib.jws.JweEncryption
import at.asitplus.wallet.lib.jws.JwkType
import at.asitplus.wallet.lib.jws.JwsAlgorithm
import at.asitplus.wallet.lib.jws.*
import at.asitplus.wallet.lib.jws.JwsExtensions.convertToAsn1Signature
import io.ktor.util.*
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.CPointer
Expand All @@ -21,6 +17,10 @@ import kotlinx.cinterop.allocArrayOf
import kotlinx.cinterop.get
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.reinterpret
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.plus
import platform.CoreFoundation.CFDataRef
import platform.CoreFoundation.CFDictionaryCreateMutable
import platform.Foundation.CFBridgingRelease
Expand Down Expand Up @@ -73,19 +73,25 @@ actual class DefaultCryptoService : CryptoService {
val publicKeyData = SecKeyCopyExternalRepresentation(publicKey, null)
val data = CFBridgingRelease(publicKeyData) as NSData
this.cryptoPublicKey = CryptoPublicKey.Ec.fromAnsiX963Bytes(EcCurve.SECP_256_R_1, data.toByteArray())!!
this.certificate = byteArrayOf() // TODO How to create a self-signed certificate in Kotlin/iOS?
val tbsCertificate = TbsCertificate(version = 2, serialNumber = 3, signatureAlgorithm = JwsAlgorithm.ES256, issuer = "SelfSigned", validFrom = Clock.System.now(), validUntil = Clock.System.now().plus(10, DateTimeUnit.MINUTE), subject = "SelfSigned", subjectPublicKey = cryptoPublicKey)
val signature = signInt(tbsCertificate.encodeToDer())
this.certificate = X509Certificate(tbsCertificate = tbsCertificate, signatureAlgorithm = JwsAlgorithm.ES256, signature = signature).encodeToDer()
}

override suspend fun sign(input: ByteArray): KmmResult<ByteArray> {
private fun signInt(input: ByteArray): ByteArray {
memScoped {
val inputData = CFBridgingRetain(toData(input)) as CFDataRef
val signature =
SecKeyCreateSignature(privateKey, kSecKeyAlgorithmECDSASignatureMessageX962SHA256, inputData, null)
val data = CFBridgingRelease(signature) as NSData
return KmmResult.success(data.toByteArray())
return data.toByteArray()
}
}

override suspend fun sign(input: ByteArray): KmmResult<ByteArray> {
return KmmResult.success(signInt(input))
}

override fun encrypt(
key: ByteArray,
iv: ByteArray,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package at.asitplus.wallet.lib.jws

import at.asitplus.wallet.lib.CryptoPublicKey
import at.asitplus.wallet.lib.agent.jcaName
import at.asitplus.wallet.lib.jws.JwsExtensions.ensureSize
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.matthewnelson.encoding.base16.Base16
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
import kotlinx.datetime.toKotlinInstant
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.cert.X509v3CertificateBuilder
import org.bouncycastle.operator.ContentSigner
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.interfaces.ECPublicKey
import java.time.Instant
import java.util.Date
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.days

class X509CertificateJvmTest : FreeSpec({

lateinit var ecCurve: EcCurve
lateinit var keyPair: KeyPair

beforeTest {
ecCurve = EcCurve.SECP_256_R_1
keyPair = KeyPairGenerator.getInstance("EC").also {
it.initialize(256)
}.genKeyPair()
}

"Certificates match" {
val ecPublicKey = keyPair.public as ECPublicKey
val keyX = ecPublicKey.w.affineX.toByteArray().ensureSize(ecCurve.coordinateLengthBytes)
val keyY = ecPublicKey.w.affineY.toByteArray().ensureSize(ecCurve.coordinateLengthBytes)
val keyId = MultibaseHelper.calcKeyId(ecCurve, keyX, keyY)!!
val cryptoPublicKey = CryptoPublicKey.Ec(curve = ecCurve, keyId = keyId, x = keyX, y = keyY)

// create certificate with bouncycastle
val notBeforeDate = Date.from(Instant.now())
val notAfterDate = Date.from(Instant.now().plusSeconds(30.days.inWholeSeconds))
val serialNumber: BigInteger = BigInteger.valueOf(Random.nextLong().absoluteValue)
val commonName = "DefaultCryptoService"
val issuer = X500Name("CN=$commonName")
val builder = X509v3CertificateBuilder(
/* issuer = */ issuer,
/* serial = */ serialNumber,
/* notBefore = */ notBeforeDate,
/* notAfter = */ notAfterDate,
/* subject = */ issuer,
/* publicKeyInfo = */ SubjectPublicKeyInfo.getInstance(keyPair.public.encoded)
)
val signatureAlgorithm = JwsAlgorithm.ES256
val contentSigner: ContentSigner = JcaContentSignerBuilder(signatureAlgorithm.jcaName).build(keyPair.private)
val certificateHolder = builder.build(contentSigner)

// create certificate with our structure
val tbsCertificate = TbsCertificate(
version = 2,
serialNumber = serialNumber.toLong(),
issuer = commonName,
validFrom = notBeforeDate.toInstant().toKotlinInstant(),
validUntil = notAfterDate.toInstant().toKotlinInstant(),
signatureAlgorithm = signatureAlgorithm,
subject = commonName,
subjectPublicKey = cryptoPublicKey
)
val signed = Signature.getInstance(signatureAlgorithm.jcaName).apply {
initSign(keyPair.private)
update(tbsCertificate.encodeToDer())
}.sign()
val x509Certificate = X509Certificate(tbsCertificate, signatureAlgorithm, signed)

val kotlinEncoded = x509Certificate.encodeToDer()
val jvmEncoded = certificateHolder.encoded
println("Certificates will never entirely match because of randomness in ECDSA signature")
//kotlinEncoded shouldBe jvmEncoded
println(kotlinEncoded.encodeToString(Base16()))
println(jvmEncoded.encodeToString(Base16()))

kotlinEncoded.drop(7).take(228) shouldBe jvmEncoded.drop(7).take(228)

val parsedFromKotlinCertificate = CertificateFactory.getInstance("X.509").generateCertificate(kotlinEncoded.inputStream())
parsedFromKotlinCertificate.verify(keyPair.public)
}

})

0 comments on commit fa7b80b

Please sign in to comment.