From 28da02c583c2eae062b2b75de178b2c530b610f3 Mon Sep 17 00:00:00 2001 From: Alexey Nechaev <11815484+tomtit@users.noreply.github.com> Date: Tue, 18 Apr 2023 19:45:39 +0300 Subject: [PATCH] Fixes #7758: Fixed JWT token for Jitsi openidtoken-jwt authentication Signed-off-by: Alexey Nechaev --- changelog.d/7758.bugfix | 1 + vector/build.gradle | 1 + .../call/conference/jwt/JitsiJWTFactory.kt | 19 ++- .../conference/jwt/JitsiJWTFactoryTest.kt | 156 ++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 changelog.d/7758.bugfix create mode 100644 vector/src/test/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactoryTest.kt diff --git a/changelog.d/7758.bugfix b/changelog.d/7758.bugfix new file mode 100644 index 0000000000..7a91a64192 --- /dev/null +++ b/changelog.d/7758.bugfix @@ -0,0 +1 @@ +Fixed JWT token for Jitsi openidtoken-jwt authentication diff --git a/vector/build.gradle b/vector/build.gradle index 5eff90448a..70a7ee2c55 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -282,6 +282,7 @@ dependencies { runtimeOnly(libs.jsonwebtoken.jjwtOrgjson) { exclude group: 'org.json', module: 'json' //provided by Android natively } + testImplementation(libs.jsonwebtoken.jjwtOrgjson) implementation 'commons-codec:commons-codec:1.15' // MapTiler diff --git a/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt b/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt index 8809606e6e..a6dd0831c7 100644 --- a/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt +++ b/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt @@ -19,8 +19,11 @@ package im.vector.app.features.call.conference.jwt import im.vector.app.core.utils.ensureProtocol import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.io.Encoders import io.jsonwebtoken.security.Keys import org.matrix.android.sdk.api.session.openid.OpenIdToken +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec import javax.inject.Inject class JitsiJWTFactory @Inject constructor() { @@ -37,7 +40,12 @@ class JitsiJWTFactory @Inject constructor() { userDisplayName: String ): String { // The secret key here is irrelevant, we're only using the JWT to transport data to Prosody in the Jitsi stack. - val key = Keys.secretKeyFor(SignatureAlgorithm.HS256) + // In the PR https://github.com/jitsi/luajwtjitsi/pull/3 the function `luajwtjitsi.decode` was removed and + // we cannot use random secret keys anymore. But the JWT library `jjwt` doesn't accept the hardcoded key `notused` + // from the module `prosody-mod-auth-matrix-user-verification` since it's too short and thus insecure. So, we + // create a new token using a random key and then re-sign the token manually with the 'weak' key. + val signatureAlgorithm = SignatureAlgorithm.HS256 + val key = Keys.secretKeyFor(signatureAlgorithm) val context = mapOf( "matrix" to mapOf( "token" to openIdToken.accessToken, @@ -52,7 +60,8 @@ class JitsiJWTFactory @Inject constructor() { // As per Jitsi token auth, `iss` needs to be set to something agreed between // JWT generating side and Prosody config. Since we have no configuration for // the widgets, we can't set one anywhere. Using the Jitsi domain here probably makes sense. - return Jwts.builder() + val token = Jwts.builder() + .setHeaderParam("typ", "JWT") .setIssuer(jitsiServerDomain) .setSubject(jitsiServerDomain) .setAudience(jitsiServerDomain.ensureProtocol()) @@ -61,5 +70,11 @@ class JitsiJWTFactory @Inject constructor() { .claim("context", context) .signWith(key) .compact() + // Re-sign token with the hardcoded key + val toSign = token.substring(0, token.lastIndexOf('.')) + val mac = Mac.getInstance(signatureAlgorithm.jcaName) + mac.init(SecretKeySpec("notused".toByteArray(), mac.algorithm)) + val prosodySignature = Encoders.BASE64URL.encode(mac.doFinal(toSign.toByteArray())) + return "$toSign.$prosodySignature" } } diff --git a/vector/src/test/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactoryTest.kt b/vector/src/test/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactoryTest.kt new file mode 100644 index 0000000000..94c2f8cbef --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactoryTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference.jwt + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.openid.OpenIdToken +import java.lang.reflect.ParameterizedType +import java.util.Base64 +import kotlin.streams.toList + +class JitsiJWTFactoryTest { + private val base64Decoder = Base64.getUrlDecoder() + private val moshi = Moshi.Builder().build() + private val stringToString = Types.newParameterizedType(Map::class.java, String::class.java, String::class.java) + private val stringToAny = Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java) + private lateinit var factory: JitsiJWTFactory + + @Before + fun init() { + factory = JitsiJWTFactory() + } + + @Test + fun `token contains 3 encoded parts`() { + val token = createToken() + + val parts = token.split(".") + assertEquals(3, parts.size) + parts.forEach { + assertTrue("Non-empty array", base64Decoder.decode(it).isNotEmpty()) + } + } + + @Test + fun `token contains unique signature`() { + val signatures = listOf("one", "two").stream() + .map { createToken(it) } + .map { it.split(".")[2] } + .map { base64Decoder.decode(it) } + .toList() + + assertEquals(2, signatures.size) + signatures.forEach { + assertEquals(32, it.size) + } + assertFalse("Unique", signatures[0].contentEquals(signatures[1])) + } + + @Test + fun `token header contains algorithm`() { + val token = createToken() + + assertEquals("HS256", parseTokenHeader(token)["alg"]) + } + + @Test + fun `token header contains type`() { + val token = createToken() + + assertEquals("JWT", parseTokenHeader(token)["typ"]) + } + + @Test + fun `token body contains subject`() { + val token = createToken() + + assertEquals("jitsi-server-domain", parseTokenBody(token)["sub"]) + } + + @Test + fun `token body contains issuer`() { + val token = createToken() + + assertEquals("jitsi-server-domain", parseTokenBody(token)["iss"]) + } + + @Test + fun `token body contains audience`() { + val token = createToken() + + assertEquals("https://jitsi-server-domain", parseTokenBody(token)["aud"]) + } + + @Test + fun `token body contains room claim`() { + val token = createToken() + + assertEquals("*", parseTokenBody(token)["room"]) + } + + @Test + fun `token body contains matrix data`() { + val token = createToken() + + assertEquals(mutableMapOf("room_id" to "room-id", "server_name" to "matrix-server-name", "token" to "matrix-token"), parseMatrixData(token)) + } + + @Test + fun `token body contains user data`() { + val token = createToken() + + assertEquals(mutableMapOf("name" to "user-display-name", "avatar" to "user-avatar-url"), parseUserData(token)) + } + + private fun createToken(): String { + return createToken("matrix-token") + } + + private fun createToken(accessToken: String): String { + val openIdToken = OpenIdToken(accessToken, "matrix-token-type", "matrix-server-name", -1) + return factory.create(openIdToken, "jitsi-server-domain", "room-id", "user-avatar-url", "user-display-name") + } + + private fun parseTokenHeader(token: String): Map { + return parseTokenPart(token.split(".")[0], stringToString) + } + + private fun parseTokenBody(token: String): Map { + return parseTokenPart(token.split(".")[1], stringToAny) + } + + private fun parseMatrixData(token: String): Map<*, *> { + return (parseTokenBody(token)["context"] as Map<*, *>)["matrix"] as Map<*, *> + } + + private fun parseUserData(token: String): Map<*, *> { + return (parseTokenBody(token)["context"] as Map<*, *>)["user"] as Map<*, *> + } + + private fun parseTokenPart(value: String, type: ParameterizedType): T { + val decoded = String(base64Decoder.decode(value)) + val adapter: JsonAdapter = moshi.adapter(type) + return adapter.fromJson(decoded)!! + } +}