Test integration of rust shield states
This commit is contained in:
parent
5f185c51e7
commit
238d10d4cb
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c87e7bd9c5e7c623708958eebddbc58c26269b0c1d81e0dc3510a657a28a6515
|
oid sha256:c025a7047c3276b09f8cfaddc6323688b4c0174385148aa20f21080ba74d236d
|
||||||
size 20719656
|
size 32306804
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package org.matrix.android.sdk.internal.crypto
|
package org.matrix.android.sdk.internal.crypto
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.test.filters.LargeTest
|
import androidx.test.filters.LargeTest
|
||||||
import org.amshove.kluent.shouldBe
|
import org.amshove.kluent.shouldBe
|
||||||
import org.junit.FixMethodOrder
|
import org.junit.FixMethodOrder
|
||||||
|
@ -98,32 +99,34 @@ class E2eeConfigTest : InstrumentedTest {
|
||||||
|
|
||||||
val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!!
|
val roomAlicePOV = cryptoTestData.firstSession.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
|
|
||||||
val beforeMessage = testHelper.sendTextMessage(roomAlicePOV, "you can read", 1).first()
|
val beforeMessage = testHelper.sendMessageInRoom(roomAlicePOV, "you can read")
|
||||||
|
|
||||||
val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!!
|
val roomBobPOV = cryptoTestData.secondSession!!.roomService().getRoom(cryptoTestData.roomId)!!
|
||||||
// ensure other received
|
// ensure other received
|
||||||
testHelper.retryPeriodically {
|
Log.v("#E2E TEST", "Wait for bob to get the message")
|
||||||
roomBobPOV.timelineService().getTimelineEvent(beforeMessage.eventId) != null
|
testHelper.ensureMessage(roomBobPOV, beforeMessage) { true }
|
||||||
}
|
|
||||||
|
|
||||||
|
Log.v("#E2E TEST", "ensure bob Can Decrypt first message")
|
||||||
cryptoTestHelper.ensureCanDecrypt(
|
cryptoTestHelper.ensureCanDecrypt(
|
||||||
listOf(beforeMessage.eventId),
|
listOf(beforeMessage),
|
||||||
cryptoTestData.secondSession!!,
|
cryptoTestData.secondSession!!,
|
||||||
cryptoTestData.roomId,
|
cryptoTestData.roomId,
|
||||||
listOf(beforeMessage.getLastMessageContent()!!.body)
|
listOf("you can read")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Log.v("#E2E TEST", "setRoomBlockUnverifiedDevices true")
|
||||||
cryptoTestData.firstSession.cryptoService().setRoomBlockUnverifiedDevices(cryptoTestData.roomId, true)
|
cryptoTestData.firstSession.cryptoService().setRoomBlockUnverifiedDevices(cryptoTestData.roomId, true)
|
||||||
|
|
||||||
val afterMessage = testHelper.sendTextMessage(roomAlicePOV, "you are blocked", 1).first()
|
Log.v("#E2E TEST", "let alice send the message")
|
||||||
|
val afterMessage = testHelper.sendMessageInRoom(roomAlicePOV, "you are blocked")
|
||||||
|
|
||||||
// ensure received
|
// ensure received
|
||||||
testHelper.retryPeriodically {
|
|
||||||
cryptoTestData.secondSession?.getRoom(cryptoTestData.roomId)?.timelineService()?.getTimelineEvent(afterMessage.eventId)?.root != null
|
Log.v("#E2E TEST", "Ensure bob received second message")
|
||||||
}
|
testHelper.ensureMessage(roomBobPOV, afterMessage) { true }
|
||||||
|
|
||||||
cryptoTestHelper.ensureCannotDecrypt(
|
cryptoTestHelper.ensureCannotDecrypt(
|
||||||
listOf(afterMessage.eventId),
|
listOf(afterMessage),
|
||||||
cryptoTestData.secondSession!!,
|
cryptoTestData.secondSession!!,
|
||||||
cryptoTestData.roomId,
|
cryptoTestData.roomId,
|
||||||
MXCryptoError.ErrorType.KEYS_WITHHELD
|
MXCryptoError.ErrorType.KEYS_WITHHELD
|
||||||
|
|
|
@ -81,7 +81,7 @@ class WithHeldTests : InstrumentedTest {
|
||||||
val timelineEvent = testHelper.sendTextMessage(roomAlicePOV, "Hello Bob", 1).first()
|
val timelineEvent = testHelper.sendTextMessage(roomAlicePOV, "Hello Bob", 1).first()
|
||||||
|
|
||||||
// await for bob unverified session to get the message
|
// await for bob unverified session to get the message
|
||||||
testHelper.retryPeriodically {
|
testHelper.retryWithBackoff {
|
||||||
bobUnverifiedSession.getRoom(roomId)?.getTimelineEvent(timelineEvent.eventId) != null
|
bobUnverifiedSession.getRoom(roomId)?.getTimelineEvent(timelineEvent.eventId) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,24 +106,26 @@ class WithHeldTests : InstrumentedTest {
|
||||||
bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
|
bobUnverifiedSession.cryptoService().decryptEvent(eventBobPOV.root, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let's see if the reply we got from bob first session is unverified
|
if (bobUnverifiedSession.cryptoService().supportsForwardedKeyWiththeld()) {
|
||||||
testHelper.retryPeriodically {
|
// Let's see if the reply we got from bob first session is unverified
|
||||||
bobUnverifiedSession.cryptoService().getOutgoingRoomKeyRequests()
|
testHelper.retryWithBackoff {
|
||||||
.firstOrNull { it.sessionId == megolmSessionId }
|
bobUnverifiedSession.cryptoService().getOutgoingRoomKeyRequests()
|
||||||
?.results
|
.firstOrNull { it.sessionId == megolmSessionId }
|
||||||
?.firstOrNull { it.fromDevice == bobSession.sessionParams.deviceId }
|
?.results
|
||||||
?.result
|
?.firstOrNull { it.fromDevice == bobSession.sessionParams.deviceId }
|
||||||
?.let {
|
?.result
|
||||||
it as? RequestResult.Failure
|
?.let {
|
||||||
}
|
it as? RequestResult.Failure
|
||||||
?.code == WithHeldCode.UNVERIFIED
|
}
|
||||||
|
?.code == WithHeldCode.UNVERIFIED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// enable back sending to unverified
|
// enable back sending to unverified
|
||||||
aliceSession.cryptoService().setGlobalBlacklistUnverifiedDevices(false)
|
aliceSession.cryptoService().setGlobalBlacklistUnverifiedDevices(false)
|
||||||
|
|
||||||
val secondEvent = testHelper.sendTextMessage(roomAlicePOV, "Verify your device!!", 1).first()
|
val secondEvent = testHelper.sendTextMessage(roomAlicePOV, "Verify your device!!", 1).first()
|
||||||
|
|
||||||
testHelper.retryPeriodically {
|
testHelper.retryWithBackoff {
|
||||||
val ev = bobUnverifiedSession.getRoom(roomId)?.getTimelineEvent(secondEvent.eventId)
|
val ev = bobUnverifiedSession.getRoom(roomId)?.getTimelineEvent(secondEvent.eventId)
|
||||||
// wait until it's decrypted
|
// wait until it's decrypted
|
||||||
ev?.root?.getClearType() == EventType.MESSAGE
|
ev?.root?.getClearType() == EventType.MESSAGE
|
||||||
|
@ -234,9 +236,19 @@ class WithHeldTests : InstrumentedTest {
|
||||||
// initialize to force request keys if missing
|
// initialize to force request keys if missing
|
||||||
cryptoTestHelper.initializeCrossSigning(bobSecondSession)
|
cryptoTestHelper.initializeCrossSigning(bobSecondSession)
|
||||||
|
|
||||||
// Trust bob second device from Alice POV
|
// // wait until alice downloaded the new device
|
||||||
aliceSession.cryptoService().crossSigningService().trustDevice(bobSecondSession.sessionParams.deviceId)
|
// testHelper.retryWithBackoff {
|
||||||
bobSecondSession.cryptoService().crossSigningService().trustDevice(aliceSession.sessionParams.deviceId)
|
// aliceSession.cryptoService().getUserDevices(bobSession.myUserId).any { it.deviceId == bobSecondSession.sessionParams.deviceId}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Trust bob second device from Alice POV
|
||||||
|
// aliceSession.cryptoService().crossSigningService().trustDevice(bobSecondSession.sessionParams.deviceId)
|
||||||
|
//
|
||||||
|
// // wait until bob downloaded alice device
|
||||||
|
// testHelper.retryWithBackoff {
|
||||||
|
// bobSecondSession.cryptoService().getUserDevices(aliceSession.myUserId).any { it.deviceId == aliceSession.sessionParams.deviceId}
|
||||||
|
// }
|
||||||
|
// bobSecondSession.cryptoService().crossSigningService().trustDevice(aliceSession.sessionParams.deviceId)
|
||||||
|
|
||||||
var sessionId: String? = null
|
var sessionId: String? = null
|
||||||
// Check that the
|
// Check that the
|
||||||
|
@ -252,10 +264,23 @@ class WithHeldTests : InstrumentedTest {
|
||||||
timeLineEvent != null
|
timeLineEvent != null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that bob second session requested the key
|
|
||||||
testHelper.retryPeriodically {
|
mustFail(
|
||||||
val wc = bobSecondSession.cryptoService().getWithHeldMegolmSession(roomAlicePov.roomId, sessionId!!)
|
message = "This session should not be able to decrypt",
|
||||||
wc?.code == WithHeldCode.UNAUTHORISED
|
failureBlock = { failure ->
|
||||||
|
val type = (failure as MXCryptoError.Base).errorType
|
||||||
|
val technicalMessage = failure.technicalMessage
|
||||||
|
Assert.assertEquals("Error should be withheld", MXCryptoError.ErrorType.KEYS_WITHHELD, type)
|
||||||
|
Assert.assertEquals("Cause should be unverified", WithHeldCode.UNVERIFIED.value, technicalMessage)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val timeLineEvent = bobSecondSession.getRoom(testData.roomId)?.getTimelineEvent(eventId)
|
||||||
|
bobSecondSession.cryptoService().decryptEvent(timeLineEvent!!.root, "")
|
||||||
}
|
}
|
||||||
|
// // Check that bob second session requested the key
|
||||||
|
// testHelper.retryPeriodically {
|
||||||
|
// val wc = bobSecondSession.cryptoService().getWithHeldMegolmSession(roomAlicePov.roomId, sessionId!!)
|
||||||
|
// wc?.code == WithHeldCode.UNAUTHORISED
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,6 +94,9 @@ interface CryptoService {
|
||||||
*/
|
*/
|
||||||
fun supportsShareKeysOnInvite(): Boolean
|
fun supportsShareKeysOnInvite(): Boolean
|
||||||
|
|
||||||
|
fun supportsKeyWithheld(): Boolean
|
||||||
|
fun supportsForwardedKeyWiththeld(): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As per MSC3061.
|
* As per MSC3061.
|
||||||
* If true will make it possible to share part of e2ee room history
|
* If true will make it possible to share part of e2ee room history
|
||||||
|
|
|
@ -38,6 +38,7 @@ import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
|
||||||
import org.matrix.android.sdk.api.session.crypto.model.ImportRoomKeysResult
|
import org.matrix.android.sdk.api.session.crypto.model.ImportRoomKeysResult
|
||||||
import org.matrix.android.sdk.api.session.crypto.model.MXEventDecryptionResult
|
import org.matrix.android.sdk.api.session.crypto.model.MXEventDecryptionResult
|
||||||
import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
|
import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
|
||||||
|
import org.matrix.android.sdk.api.session.crypto.model.MessageVerificationState
|
||||||
import org.matrix.android.sdk.api.session.crypto.model.UnsignedDeviceInfo
|
import org.matrix.android.sdk.api.session.crypto.model.UnsignedDeviceInfo
|
||||||
import org.matrix.android.sdk.api.session.crypto.verification.VerificationTransaction
|
import org.matrix.android.sdk.api.session.crypto.verification.VerificationTransaction
|
||||||
import org.matrix.android.sdk.api.session.events.model.Content
|
import org.matrix.android.sdk.api.session.events.model.Content
|
||||||
|
@ -77,6 +78,8 @@ import org.matrix.rustcomponents.sdk.crypto.MegolmV1BackupKey
|
||||||
import org.matrix.rustcomponents.sdk.crypto.Request
|
import org.matrix.rustcomponents.sdk.crypto.Request
|
||||||
import org.matrix.rustcomponents.sdk.crypto.RequestType
|
import org.matrix.rustcomponents.sdk.crypto.RequestType
|
||||||
import org.matrix.rustcomponents.sdk.crypto.RoomKeyCounts
|
import org.matrix.rustcomponents.sdk.crypto.RoomKeyCounts
|
||||||
|
import org.matrix.rustcomponents.sdk.crypto.ShieldColor
|
||||||
|
import org.matrix.rustcomponents.sdk.crypto.ShieldState
|
||||||
import org.matrix.rustcomponents.sdk.crypto.setLogger
|
import org.matrix.rustcomponents.sdk.crypto.setLogger
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -180,7 +183,7 @@ internal class OlmMachine @Inject constructor(
|
||||||
|
|
||||||
val crossSigningVerified = when (val ownIdentity = getIdentity(userId())) {
|
val crossSigningVerified = when (val ownIdentity = getIdentity(userId())) {
|
||||||
is OwnUserIdentity -> ownIdentity.trustsOurOwnDevice()
|
is OwnUserIdentity -> ownIdentity.trustsOurOwnDevice()
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
return CryptoDeviceInfo(
|
return CryptoDeviceInfo(
|
||||||
|
@ -437,7 +440,7 @@ internal class OlmMachine @Inject constructor(
|
||||||
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON)
|
throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON)
|
||||||
}
|
}
|
||||||
val serializedEvent = adapter.toJson(event)
|
val serializedEvent = adapter.toJson(event)
|
||||||
val decrypted = inner.decryptRoomEvent(serializedEvent, event.roomId, false)
|
val decrypted = inner.decryptRoomEvent(serializedEvent, event.roomId, false, false)
|
||||||
|
|
||||||
val deserializationAdapter =
|
val deserializationAdapter =
|
||||||
moshi.adapter<JsonDict>(Map::class.java)
|
moshi.adapter<JsonDict>(Map::class.java)
|
||||||
|
@ -449,16 +452,20 @@ internal class OlmMachine @Inject constructor(
|
||||||
senderCurve25519Key = decrypted.senderCurve25519Key,
|
senderCurve25519Key = decrypted.senderCurve25519Key,
|
||||||
claimedEd25519Key = decrypted.claimedEd25519Key,
|
claimedEd25519Key = decrypted.claimedEd25519Key,
|
||||||
forwardingCurve25519KeyChain = decrypted.forwardingCurve25519Chain,
|
forwardingCurve25519KeyChain = decrypted.forwardingCurve25519Chain,
|
||||||
messageVerificationState = decrypted.verificationState.fromInner(),
|
messageVerificationState = decrypted.shieldState.toVerificationState(),
|
||||||
)
|
)
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
val reThrow = when (throwable) {
|
val reThrow = when (throwable) {
|
||||||
is DecryptionException.MissingRoomKey -> {
|
is DecryptionException.MissingRoomKey -> {
|
||||||
MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID, throwable.message.orEmpty())
|
if (throwable.withheldCode != null) {
|
||||||
|
MXCryptoError.Base(MXCryptoError.ErrorType.KEYS_WITHHELD, throwable.withheldCode!!)
|
||||||
|
} else {
|
||||||
|
MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID, throwable.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is DecryptionException.Megolm -> {
|
is DecryptionException.Megolm -> {
|
||||||
// TODO check if it's the correct binding?
|
// TODO check if it's the correct binding?
|
||||||
MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX, throwable.message.orEmpty())
|
MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_MESSAGE_INDEX, throwable.error)
|
||||||
}
|
}
|
||||||
is DecryptionException.Identifier -> {
|
is DecryptionException.Identifier -> {
|
||||||
MXCryptoError.Base(MXCryptoError.ErrorType.BAD_EVENT_FORMAT, MXCryptoError.BAD_EVENT_FORMAT_TEXT_REASON)
|
MXCryptoError.Base(MXCryptoError.ErrorType.BAD_EVENT_FORMAT, MXCryptoError.BAD_EVENT_FORMAT_TEXT_REASON)
|
||||||
|
@ -480,6 +487,24 @@ internal class OlmMachine @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ShieldState.toVerificationState(): MessageVerificationState? {
|
||||||
|
return when (this.color) {
|
||||||
|
ShieldColor.GREEN -> MessageVerificationState.VERIFIED
|
||||||
|
ShieldColor.RED -> {
|
||||||
|
when (this.message) {
|
||||||
|
"Encrypted by an unverified device." -> MessageVerificationState.UN_SIGNED_DEVICE
|
||||||
|
"Encrypted by a device not verified by its owner." -> MessageVerificationState.UN_SIGNED_DEVICE_OF_VERIFIED_USER
|
||||||
|
"Encrypted by an unknown or deleted device." -> MessageVerificationState.UNKNOWN_DEVICE
|
||||||
|
else -> MessageVerificationState.UN_SIGNED_DEVICE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShieldColor.GRAY -> {
|
||||||
|
MessageVerificationState.UNSAFE_SOURCE
|
||||||
|
}
|
||||||
|
ShieldColor.NONE -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request the room key that was used to encrypt the given undecrypted event.
|
* Request the room key that was used to encrypt the given undecrypted event.
|
||||||
*
|
*
|
||||||
|
|
|
@ -727,9 +727,13 @@ internal class RustCryptoService @Inject constructor(
|
||||||
|
|
||||||
override fun supportsShareKeysOnInvite() = false
|
override fun supportsShareKeysOnInvite() = false
|
||||||
|
|
||||||
|
override fun supportsKeyWithheld() = true
|
||||||
|
override fun supportsForwardedKeyWiththeld() = false
|
||||||
|
|
||||||
|
|
||||||
override fun enableShareKeyOnInvite(enable: Boolean) {
|
override fun enableShareKeyOnInvite(enable: Boolean) {
|
||||||
if (enable) {
|
if (enable && !supportsShareKeysOnInvite()) {
|
||||||
TODO("Enable share key on invite not implemented")
|
throw java.lang.UnsupportedOperationException("Enable share key on invite not implemented in rust");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -872,10 +876,12 @@ internal class RustCryptoService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSharedWithInfo(roomId: String?, sessionId: String): MXUsersDevicesMap<Int> {
|
override fun getSharedWithInfo(roomId: String?, sessionId: String): MXUsersDevicesMap<Int> {
|
||||||
|
// TODO not exposed in rust?
|
||||||
return cryptoStore.getSharedWithInfo(roomId, sessionId)
|
return cryptoStore.getSharedWithInfo(roomId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getWithHeldMegolmSession(roomId: String, sessionId: String): RoomKeyWithHeldContent? {
|
override fun getWithHeldMegolmSession(roomId: String, sessionId: String): RoomKeyWithHeldContent? {
|
||||||
|
// TODO not exposed in rust.
|
||||||
return cryptoStore.getWithHeldMegolmSession(roomId, sessionId)
|
return cryptoStore.getWithHeldMegolmSession(roomId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
||||||
*
|
|
||||||
* 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 org.matrix.android.sdk.internal.crypto
|
|
||||||
|
|
||||||
import org.matrix.android.sdk.api.session.crypto.model.MessageVerificationState
|
|
||||||
import org.matrix.rustcomponents.sdk.crypto.VerificationState as InnerVerificationState
|
|
||||||
|
|
||||||
fun InnerVerificationState.fromInner(): MessageVerificationState {
|
|
||||||
return when (this) {
|
|
||||||
InnerVerificationState.VERIFIED -> MessageVerificationState.VERIFIED
|
|
||||||
InnerVerificationState.SIGNED_DEVICE_OF_UNVERIFIED_USER -> MessageVerificationState.SIGNED_DEVICE_OF_UNVERIFIED_USER
|
|
||||||
InnerVerificationState.UN_SIGNED_DEVICE_OF_VERIFIED_USER -> MessageVerificationState.UN_SIGNED_DEVICE_OF_VERIFIED_USER
|
|
||||||
InnerVerificationState.UN_SIGNED_DEVICE_OF_UNVERIFIED_USER -> MessageVerificationState.UN_SIGNED_DEVICE
|
|
||||||
InnerVerificationState.UNKNOWN_DEVICE -> MessageVerificationState.UNKNOWN_DEVICE
|
|
||||||
InnerVerificationState.UNSAFE_SOURCE -> MessageVerificationState.UNSAFE_SOURCE
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue