Skip to content

Commit

Permalink
Implement simple COSE signing and verification
Browse files Browse the repository at this point in the history
  • Loading branch information
nodh committed Jul 18, 2023
1 parent 44a7d09 commit a9b807d
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package at.asitplus.wallet.lib.agent

import at.asitplus.KmmResult
import at.asitplus.wallet.lib.cbor.CoseAlgorithm
import at.asitplus.wallet.lib.cbor.CoseKey
import at.asitplus.wallet.lib.jws.EcCurve
import at.asitplus.wallet.lib.jws.JsonWebKey
import at.asitplus.wallet.lib.jws.JweAlgorithm
Expand Down Expand Up @@ -45,8 +47,12 @@ interface CryptoService {

val jwsAlgorithm: JwsAlgorithm

val coseAlgorithm: CoseAlgorithm

fun toJsonWebKey(): JsonWebKey

fun toCoseKey(): CoseKey

}

interface VerifierCryptoService {
Expand All @@ -58,6 +64,13 @@ interface VerifierCryptoService {
publicKey: JsonWebKey
): KmmResult<Boolean>

fun verify(
input: ByteArray,
signature: ByteArray,
algorithm: CoseAlgorithm,
publicKey: CoseKey
): KmmResult<Boolean>

}

expect object CryptoUtils {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ enum class CoseAlgorithm(val value: Int) {
ES384(-35),
ES512(-36);

val signatureValueLength
get() = when (this) {
ES256 -> 256 / 8
ES384 -> 384 / 8
ES512 -> 512 / 8
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,32 @@ enum class CoseEllipticCurve(val value: Int) {

P256(1),
P384(2),
P521(3),
X25519(4),
X448(5),
Ed25519(6),
Ed448(7);
P521(3);
//X25519(4),
//X448(5),
//Ed25519(6),
//Ed448(7);

val keyLengthBits
get() = when (this) {
P256 -> 256
P384 -> 384
P521 -> 521
}

val coordinateLengthBytes
get() = when (this) {
P256 -> 256 / 8
P384 -> 384 / 8
P521 -> 521 / 8
}

val signatureLengthBytes
get() = when (this) {
P256 -> 256 / 8
P384 -> 384 / 8
P521 -> 521 / 8
}

}

Expand Down
52 changes: 52 additions & 0 deletions vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/cbor/CoseKey.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package at.asitplus.wallet.lib.cbor

import at.asitplus.KmmResult
import at.asitplus.wallet.lib.iso.cborSerializer
import at.asitplus.wallet.lib.jws.EcCurve
import at.asitplus.wallet.lib.jws.JsonWebKey
import at.asitplus.wallet.lib.jws.JwkType
import at.asitplus.wallet.lib.jws.MultibaseHelper
import io.github.aakira.napier.Napier
import io.matthewnelson.component.base64.encodeBase64
import kotlinx.serialization.ExperimentalSerializationApi
Expand Down Expand Up @@ -46,6 +51,12 @@ data class CoseKey(
) {
fun serialize() = cborSerializer.encodeToByteArray(this)

fun toAnsiX963ByteArray(): KmmResult<ByteArray> {
if (x != null && y != null)
return KmmResult.success(byteArrayOf(0x04.toByte()) + x + y);
return KmmResult.failure(IllegalArgumentException())
}

companion object {

fun deserialize(it: ByteArray) = kotlin.runCatching {
Expand All @@ -55,6 +66,47 @@ data class CoseKey(
null
}

fun fromAnsiX963Bytes(type: CoseKeyType, curve: CoseEllipticCurve, it: ByteArray): CoseKey? {
if (type != CoseKeyType.EC2 || curve != CoseEllipticCurve.P256) {
return null
}
if (it.size != 1 + 32 + 32 || it[0] != 0x04.toByte()) {
return null
}
val xCoordinate = it.sliceArray(1 until 33)
val yCoordinate = it.sliceArray(33 until 65)
val keyId = MultibaseHelper.calcKeyId(curve, xCoordinate, yCoordinate)
?: return null
return CoseKey(
type = type,
keyId = keyId.encodeToByteArray(),
algorithm = CoseAlgorithm.ES256,
curve = curve,
x = xCoordinate,
y = yCoordinate,
)
}

fun fromCoordinates(
type: CoseKeyType,
curve: CoseEllipticCurve,
x: ByteArray,
y: ByteArray
): CoseKey? {
if (type != CoseKeyType.EC2 || curve != CoseEllipticCurve.P256) {
return null
}
val keyId = MultibaseHelper.calcKeyId(curve, x, y)
?: return null
return CoseKey(
type = type,
keyId = keyId.encodeToByteArray(),
algorithm = CoseAlgorithm.ES256,
curve = curve,
x = x,
y = y
)
}
}

override fun equals(other: Any?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package at.asitplus.wallet.lib.cbor

import at.asitplus.KmmResult
import at.asitplus.wallet.lib.agent.CryptoService
import at.asitplus.wallet.lib.agent.DefaultVerifierCryptoService
import at.asitplus.wallet.lib.agent.VerifierCryptoService
import at.asitplus.wallet.lib.jws.JwsExtensions.extractSignatureValues
import io.github.aakira.napier.Napier
import kotlinx.serialization.cbor.ByteStringWrapper

/**
* Creates and parses COSE objects.
*/
interface CoseService {

/**
* Appends correct values for [CoseHeader.kid], [CoseHeader.algorithm],
* if the corresponding options are set
*/
suspend fun createSignedCose(
protectedHeader: CoseHeader,
unprotectedHeader: CoseHeader,
payload: ByteArray,
addKeyId: Boolean = true,
): KmmResult<CoseSigned>
}

interface VerifierCoseService {

fun verifyCose(coseSigned: CoseSigned, signer: CoseKey): KmmResult<Boolean>

}

class DefaultCoseService(private val cryptoService: CryptoService) : CoseService {

override suspend fun createSignedCose(
protectedHeader: CoseHeader,
unprotectedHeader: CoseHeader,
payload: ByteArray,
addKeyId: Boolean,
): KmmResult<CoseSigned> {
var copy = protectedHeader.copy(algorithm = cryptoService.coseAlgorithm)
if (addKeyId)
copy = copy.copy(kid = cryptoService.identifier)

val signatureInput = CoseSignatureInput(
contextString = "Signature1",
protectedHeader = ByteStringWrapper(copy),
payload = payload,
).serialize()

val signature = cryptoService.sign(signatureInput).getOrElse {
Napier.w("No signature from native code", it)
return KmmResult.failure(it)
}
val rawSignature = signature.extractSignatureValues(cryptoService.coseAlgorithm.signatureValueLength)
return KmmResult.success(
CoseSigned(ByteStringWrapper(copy), unprotectedHeader, payload, rawSignature)
)
}
}

class DefaultVerifierCoseService(
private val cryptoService: VerifierCryptoService = DefaultVerifierCryptoService()
) : VerifierCoseService {

/**
* Verifiers the signature of [coseSigned] by using [signer].
*/
override fun verifyCose(coseSigned: CoseSigned, signer: CoseKey): KmmResult<Boolean> {
val signatureInput = CoseSignatureInput(
contextString = "Signature1",
protectedHeader = ByteStringWrapper(coseSigned.protectedHeader.value),
payload = coseSigned.payload,
).serialize()

val algorithm = coseSigned.protectedHeader.value.algorithm
?: return KmmResult.failure(IllegalArgumentException("Algorithm not specified"))
val verified = cryptoService.verify(signatureInput, coseSigned.signature, algorithm, signer)
val result = verified.getOrElse {
Napier.w("No verification from native code", it)
return KmmResult.failure(it)
}
return KmmResult.success(result)
}
}



Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,51 @@ data class CoseSigned(
}
}

@OptIn(ExperimentalSerializationApi::class)
@Serializable
@CborArray
data class CoseSignatureInput(
val contextString: String = "Signature1",
@Serializable(with = ByteStringWrapperSerializer::class)
@ByteString
val protectedHeader: ByteStringWrapper<CoseHeader>,
@ByteString
val externalAad: ByteArray = byteArrayOf(),
@ByteString
val payload: ByteArray,
){
fun serialize() = cborSerializer.encodeToByteArray(this)

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

other as CoseSignatureInput

if (contextString != other.contextString) return false
if (protectedHeader != other.protectedHeader) return false
if (!externalAad.contentEquals(other.externalAad)) return false
return payload.contentEquals(other.payload)
}

override fun hashCode(): Int {
var result = contextString.hashCode()
result = 31 * result + protectedHeader.hashCode()
result = 31 * result + externalAad.contentHashCode()
result = 31 * result + payload.contentHashCode()
return result
}

companion object {
fun deserialize(it: ByteArray) = kotlin.runCatching {
cborSerializer.decodeFromByteArray<CoseSignatureInput>(it)
}.getOrElse {
Napier.w("deserialize failed", it)
null
}
}
}

object ByteStringWrapperSerializer : KSerializer<ByteStringWrapper<CoseHeader>> {

override val descriptor: SerialDescriptor =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package at.asitplus.wallet.lib.jws

import at.asitplus.wallet.lib.cbor.CoseEllipticCurve
import io.matthewnelson.component.base64.decodeBase64ToArray
import io.matthewnelson.component.base64.encodeBase64

Expand All @@ -21,6 +22,19 @@ object MultibaseHelper {
return "$PREFIX_DID_KEY:${multibaseWrapBase64(multicodecWrapP256(encodeP256Key(x, y)))}"
}

/**
* Returns something like `did:key:mEpA...` with the [x] and [y] values appended in Base64.
* This translates to `Base64(0x12, 0x90, EC-P-256-Key)`.
* Note that `0x1290` is not an official Multicodec prefix, but there seems to be none for
* uncompressed P-256 key. We can't use the compressed format, because decoding that would
* require some EC Point math...
*/
fun calcKeyId(curve: CoseEllipticCurve, x: ByteArray, y: ByteArray): String? {
if (curve != CoseEllipticCurve.P256)
return null
return "$PREFIX_DID_KEY:${multibaseWrapBase64(multicodecWrapP256(encodeP256Key(x, y)))}"
}

fun calcPublicKey(keyId: String): Pair<ByteArray, ByteArray>? {
if (!keyId.startsWith("$PREFIX_DID_KEY:")) return null
val stripped = keyId.removePrefix("$PREFIX_DID_KEY:")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package at.asitplus.wallet.lib.cbor

import at.asitplus.wallet.lib.agent.CryptoService
import at.asitplus.wallet.lib.agent.DefaultCryptoService
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.matthewnelson.component.encoding.base16.encodeBase16
import kotlin.random.Random

class CoseServiceTest : FreeSpec({

lateinit var cryptoService: CryptoService
lateinit var coseService: CoseService
lateinit var verifierCoseService: VerifierCoseService
lateinit var randomPayload: ByteArray

beforeEach {
cryptoService = DefaultCryptoService()
coseService = DefaultCoseService(cryptoService)
verifierCoseService = DefaultVerifierCoseService()
randomPayload = Random.nextBytes(32)
}

"signed object with bytes can be verified" {
val signed =
coseService.createSignedCose(CoseHeader(algorithm = CoseAlgorithm.ES256), CoseHeader(), randomPayload, true)
.getOrThrow()
signed.shouldNotBeNull()
println(signed.serialize().encodeBase16())

signed.payload shouldBe randomPayload
signed.signature.shouldNotBeNull()

// TODO activate once serialization works
//val parsed = CoseSigned.deserialize(signed.serialize())
//parsed.shouldNotBeNull()
val parsed = signed

val result = verifierCoseService.verifyCose(parsed, cryptoService.toCoseKey()).getOrThrow()
result shouldBe true
}

})
Loading

0 comments on commit a9b807d

Please sign in to comment.