diff --git a/CHANGELOG.md b/CHANGELOG.md index 0086169b4..25bc43b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,9 @@ ### NEXT +**Fixes** + * Fix calculation of JWK thumbprints according to [RFC7638](https://www.rfc-editor.org/rfc/rfc7638.html) + **Changes** * Add `provider` module that actually implements cryptography! * Add `COSE_Key` header to `CoseHeader`, defined in OpenID for Verifiable Credential Issuance draft 13 diff --git a/datatypes-jws/src/commonMain/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKey.kt b/datatypes-jws/src/commonMain/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKey.kt index 54c237049..68af45d28 100644 --- a/datatypes-jws/src/commonMain/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKey.kt +++ b/datatypes-jws/src/commonMain/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKey.kt @@ -21,13 +21,28 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject import okio.ByteString.Companion.toByteString /** - * JSON Web Key as per [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517#section-4) + * JSON Web Key as per [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517#section-4). + * + * Note that the members are ordered lexicographically, as required for JWK Thumbprint calculation, + * see [RFC7638 s3](https://www.rfc-editor.org/rfc/rfc7638.html#section-3) */ @Serializable data class JsonWebKey( + /** + * The "alg" (algorithm) parameter identifies the algorithm intended for + * use with the key. The values used should either be registered in the + * IANA "JSON Web Signature and Encryption Algorithms" registry + * established by [JWA] or be a value that contains a Collision- + * Resistant Name. The "alg" value is a case-sensitive ASCII string. + * Use of this member is OPTIONAL. + */ + @SerialName("alg") + val algorithm: JsonWebAlgorithm? = null, + /** * Set for EC keys only */ @@ -35,15 +50,27 @@ data class JsonWebKey( val curve: ECCurve? = null, /** - * The "kty" (key type) parameter identifies the cryptographic algorithm - * family used with the key, such as "RSA" or "EC". "kty" values should - * either be registered in the IANA "JSON Web Key Types" registry - * established by (JWA) or be a value that contains a Collision-Resistant - * Name. The "kty" value is a case-sensitive string. This - * member MUST be present in a JWK. + * Set for RSA keys only */ - @SerialName("kty") - val type: JwkType? = null, + @SerialName("e") + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val e: ByteArray? = null, + + /** + * Set for symmetric keys only + */ + @SerialName("k") + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val k: ByteArray? = null, + + /** + * The "key_ops" (key operations) parameter identifies the operation(s) + * for which the key is intended to be used. The "key_ops" parameter is + * intended for use cases in which public, private, or symmetric keys + * may be present. + */ + @SerialName("key_ops") + val keyOperations: Set? = null, /** * The "kid" (key ID) parameter is used to match a specific key. This @@ -62,18 +89,15 @@ data class JsonWebKey( val keyId: String? = null, /** - * Set for EC keys only - */ - @SerialName("x") - @Serializable(with = ByteArrayBase64UrlSerializer::class) - val x: ByteArray? = null, - - /** - * Set for EC keys only + * The "kty" (key type) parameter identifies the cryptographic algorithm + * family used with the key, such as "RSA" or "EC". "kty" values should + * either be registered in the IANA "JSON Web Key Types" registry + * established by (JWA) or be a value that contains a Collision-Resistant + * Name. The "kty" value is a case-sensitive string. This + * member MUST be present in a JWK. */ - @SerialName("y") - @Serializable(with = ByteArrayBase64UrlSerializer::class) - val y: ByteArray? = null, + @SerialName("kty") + val type: JwkType? = null, /** * Set for RSA keys only @@ -82,20 +106,6 @@ data class JsonWebKey( @Serializable(with = ByteArrayBase64UrlSerializer::class) val n: ByteArray? = null, - /** - * Set for RSA keys only - */ - @SerialName("e") - @Serializable(with = ByteArrayBase64UrlSerializer::class) - val e: ByteArray? = null, - - /** - * Set for symmetric keys only - */ - @SerialName("k") - @Serializable(with = ByteArrayBase64UrlSerializer::class) - val k: ByteArray? = null, - /** * The "use" (public key use) parameter identifies the intended use of * the public key. The "use" parameter is employed to indicate whether @@ -106,41 +116,11 @@ data class JsonWebKey( val publicKeyUse: String? = null, /** - * The "key_ops" (key operations) parameter identifies the operation(s) - * for which the key is intended to be used. The "key_ops" parameter is - * intended for use cases in which public, private, or symmetric keys - * may be present. - */ - @SerialName("key_ops") - val keyOperations: Set? = null, - - /** - * The "alg" (algorithm) parameter identifies the algorithm intended for - * use with the key. The values used should either be registered in the - * IANA "JSON Web Signature and Encryption Algorithms" registry - * established by [JWA] or be a value that contains a Collision- - * Resistant Name. The "alg" value is a case-sensitive ASCII string. - * Use of this member is OPTIONAL. - */ - @SerialName("alg") - val algorithm: JsonWebAlgorithm? = null, - - /** - * The "x5u" (X.509 URL) parameter is a URI (RFC3986) that refers to a - * resource for an X.509 public key certificate or certificate chain - * (RFC5280). The identified resource MUST provide a representation of - * the certificate or certificate chain that conforms to RFC 5280 - * (RFC5280) in PEM-encoded form, with each certificate delimited as - * specified in Section 6.1 of RFC 4945 (RFC4945). The key in the first - * certificate MUST match the public key represented by other members of - * the JWK. The protocol used to acquire the resource MUST provide - * integrity protection; an HTTP GET request to retrieve the certificate - * MUST use TLS (RFC2818) (RFC5246); the identity of the server MUST be - * validated, as per Section 6 of RFC 6125 (RFC6125). Use of this - * member is OPTIONAL. + * Set for EC keys only */ - @SerialName("x5u") - val certificateUrl: String? = null, + @SerialName("x") + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val x: ByteArray? = null, /** * The "x5c" (X.509 certificate chain) parameter contains a chain of one @@ -170,6 +150,23 @@ data class JsonWebKey( @Serializable(with = ByteArrayBase64UrlSerializer::class) val certificateSha1Thumbprint: ByteArray? = null, + /** + * The "x5u" (X.509 URL) parameter is a URI (RFC3986) that refers to a + * resource for an X.509 public key certificate or certificate chain + * (RFC5280). The identified resource MUST provide a representation of + * the certificate or certificate chain that conforms to RFC 5280 + * (RFC5280) in PEM-encoded form, with each certificate delimited as + * specified in Section 6.1 of RFC 4945 (RFC4945). The key in the first + * certificate MUST match the public key represented by other members of + * the JWK. The protocol used to acquire the resource MUST provide + * integrity protection; an HTTP GET request to retrieve the certificate + * MUST use TLS (RFC2818) (RFC5246); the identity of the server MUST be + * validated, as per Section 6 of RFC 6125 (RFC6125). Use of this + * member is OPTIONAL. + */ + @SerialName("x5u") + val certificateUrl: String? = null, + /** * The "x5t#S256" (X.509 certificate SHA-256 thumbprint) parameter is a * base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER @@ -181,6 +178,13 @@ data class JsonWebKey( @SerialName("x5t#S256") @Serializable(with = ByteArrayBase64UrlSerializer::class) val certificateSha256Thumbprint: ByteArray? = null, + + /** + * Set for EC keys only + */ + @SerialName("y") + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val y: ByteArray? = null, ) : SpecializedCryptoPublicKey { /** @@ -189,7 +193,10 @@ data class JsonWebKey( * See [RFC9278](https://www.rfc-editor.org/rfc/rfc9278.html) */ val jwkThumbprint: String by lazy { - val thumbprint = Json.encodeToString(this).encodeToByteArray().toByteString().sha256().base64Url() + val jsonEncoded = Json.encodeToString(this.toMinimalJsonWebKey().getOrNull() ?: this) + .also { println(it) } + val thumbprint = jsonEncoded + .encodeToByteArray().toByteString().sha256().toByteArray().encodeToString(Base64UrlStrict) "urn:ietf:params:oauth:jwk-thumbprint:sha256:${thumbprint}" } @@ -306,6 +313,19 @@ data class JsonWebKey( } } + /** + * @return a copy of this key with the minimal required members as listed in + * [RFC7638 3.2](https://www.rfc-editor.org/rfc/rfc7638.html#section-3.2) + */ + fun toMinimalJsonWebKey(): KmmResult = catching { + when (type) { + JwkType.EC -> JsonWebKey(type = JwkType.EC, curve = curve, x = x, y = y) + JwkType.RSA -> JsonWebKey(type = JwkType.RSA, n = n, e = e) + JwkType.SYM -> JsonWebKey(type = JwkType.SYM, k = k) + else -> throw IllegalArgumentException("Illegal key type") + } + } + /** * Contains convenience functions */ diff --git a/datatypes-jws/src/commonTest/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKeyTest.kt b/datatypes-jws/src/commonTest/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKeyTest.kt new file mode 100644 index 000000000..aeb0d3919 --- /dev/null +++ b/datatypes-jws/src/commonTest/kotlin/at/asitplus/crypto/datatypes/jws/JsonWebKeyTest.kt @@ -0,0 +1,79 @@ +package at.asitplus.crypto.datatypes.jws + +import at.asitplus.crypto.datatypes.ECCurve +import at.asitplus.crypto.datatypes.io.Base64UrlStrict +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray +import kotlin.random.Random + +class JsonWebKeyTest : FreeSpec({ + + lateinit var curve: ECCurve + lateinit var x: ByteArray + lateinit var y: ByteArray + lateinit var ecKey: JsonWebKey + lateinit var n: ByteArray + lateinit var e: ByteArray + lateinit var rsaKey: JsonWebKey + + beforeTest { + curve = ECCurve.SECP_256_R_1 + x = Random.Default.nextBytes(32) + y = Random.Default.nextBytes(32) + ecKey = JsonWebKey(type = JwkType.EC, curve = curve, x = x, y = y) + n = Random.Default.nextBytes(1024) + e = Random.Default.nextBytes(16) + rsaKey = JsonWebKey(type = JwkType.RSA, n = n, e = e) + } + + "Thumbprint for minimal EC Key" - { + val newKey = JsonWebKey(type = JwkType.EC, curve = curve, x = x, y = y) + + newKey.jwkThumbprint shouldBe ecKey.jwkThumbprint + } + + "Thumbprint for EC Key with additional properties" - { + val newKey = JsonWebKey(type = JwkType.EC, curve = curve, x = x, y = y, publicKeyUse = "foo") + + newKey.jwkThumbprint shouldBe ecKey.jwkThumbprint + } + + "Thumbprint for EC Key with keyId" - { + val newKey = JsonWebKey(type = JwkType.EC, curve = curve, x = x, y = y, keyId = "foo") + + newKey.jwkThumbprint shouldBe ecKey.jwkThumbprint + } + + "Thumbprint for minimal RSA Key" - { + val newKey = JsonWebKey(type = JwkType.RSA, n = n, e = e) + + newKey.jwkThumbprint shouldBe rsaKey.jwkThumbprint + } + + "Thumbprint for RSA Key with additional properties" - { + val newKey = JsonWebKey(type = JwkType.RSA, n = n, e = e, algorithm = JwsAlgorithm.RS256) + + newKey.jwkThumbprint shouldBe rsaKey.jwkThumbprint + } + + "Thumbprint for RSA Key with keyId" - { + val newKey = JsonWebKey(type = JwkType.RSA, n = n, e = e, keyId = "foo") + + newKey.jwkThumbprint shouldBe rsaKey.jwkThumbprint + } + + "Thumbprint for fixed Key from RFC 7638" - { + val parsedN = ("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2" + + "aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCi" + + "FV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65Y" + + "GjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n" + + "91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_x" + + "BniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw").decodeToByteArray(Base64UrlStrict) + val parsedE = "AQAB".decodeToByteArray(Base64UrlStrict) + val key = JsonWebKey(type = JwkType.RSA, n = parsedN, e = parsedE) + + key.jwkThumbprint shouldBe "urn:ietf:params:oauth:jwk-thumbprint:sha256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" + } + +})