Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Small Status List Updates #95

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion credentials/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dependencies {
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-jackson:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("io.ktor:ktor-client-logging:$ktor_version")

testImplementation("io.ktor:ktor-client-mock:$ktor_version")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import io.ktor.serialization.jackson.jackson
Expand Down Expand Up @@ -88,19 +89,19 @@
try {
URI.create(statusListCredentialId)
} catch (e: Exception) {
throw IllegalArgumentException("status list credential id is not a valid URI", e)
throw IllegalArgumentException("Status list credential id is not a valid URI", e)
}

try {
URI.create(issuer)
} catch (e: Exception) {
throw IllegalArgumentException("issuer is not a valid URI", e)
throw IllegalArgumentException("Issuer is not a valid URI", e)
}

try {
DidResolvers.resolve(issuer)
} catch (e: Exception) {
throw IllegalArgumentException("issuer: $issuer not resolvable", e)
throw IllegalArgumentException("Issuer: $issuer not resolvable", e)

Check warning on line 104 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L104

Added line #L104 was not covered by tests
}

val claims = mapOf(STATUS_PURPOSE to statusPurpose.toString().lowercase(), ENCODED_LIST to bitString)
Expand Down Expand Up @@ -192,23 +193,25 @@
credentialToValidate: VerifiableCredential,
httpClient: HttpClient? = null // default HTTP client but can be overridden
): Boolean {
return runBlocking {
var isDefaultClient = false
val clientToUse = httpClient ?: defaultHttpClient().also { isDefaultClient = true }

try {
val statusListEntryValue: StatusList2021Entry =
StatusList2021Entry.fromJsonObject(credentialToValidate.vcDataModel.credentialStatus.jsonObject)
val statusListCredential =
clientToUse.fetchStatusListCredential(statusListEntryValue.statusListCredential.toString())
val statusListCredential: VerifiableCredential

return@runBlocking validateCredentialInStatusList(credentialToValidate, statusListCredential)
runBlocking {
statusListCredential =
clientToUse.fetchStatusListCredential(statusListEntryValue.statusListCredential.toString())
}

return validateCredentialInStatusList(credentialToValidate, statusListCredential)
} finally {
if (isDefaultClient) {
clientToUse.close()
}
}
}
}

private fun defaultHttpClient(): HttpClient {
Expand All @@ -220,18 +223,23 @@
}

private suspend fun HttpClient.fetchStatusListCredential(url: String): VerifiableCredential {
val response: HttpResponse

try {
val response: io.ktor.client.statement.HttpResponse = this.get(url)
if (response.status.isSuccess()) {
val body = response.bodyAsText()
return VerifiableCredential.parseJwt(body)
} else {
throw ClientRequestException(response, "Failed to retrieve VerifiableCredentialType from $url")
}
} catch (e: ClientRequestException) {
throw Exception("Failed to fetch the status list credential due to a request error: ${e.message}", e)
} catch (e: ResponseException) {
throw Exception("Failed to fetch the status list credential due to a response error: ${e.message}", e)
response = this.get(url)
} catch (e: Exception) {
throw RuntimeException("Failed to retrieve VerifiableCredentialType from $url", e)
}

require(response.status.isSuccess()) {
"Failed to retrieve VerifiableCredentialType from $url with status ${response.status}"

Check warning on line 235 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L235

Added line #L235 was not covered by tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throws an IllegalArgumentException. Is that what you intended?

}

try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this try/catch block could be removed to simplify the code.

val body = response.bodyAsText()
return VerifiableCredential.parseJwt(body)
} catch(e: Exception) {
throw RuntimeException("Failed to fetch the status list credential", e)

Check warning on line 242 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L241-L242

Added lines #L241 - L242 were not covered by tests
}
}

Expand All @@ -249,15 +257,15 @@
): List<String> {
val duplicateSet = mutableSetOf<String>()
for (vc in credentials) {
requireNotNull(vc.vcDataModel.credentialStatus) { "no credential status found in credential" }
requireNotNull(vc.vcDataModel.credentialStatus) { "No credential status found in credential" }

val statusListEntry: StatusList2021Entry =
StatusList2021Entry.fromJsonObject(vc.vcDataModel.credentialStatus.jsonObject)

require(statusListEntry.statusPurpose == statusPurpose.toString().lowercase()) { "status purpose mismatch" }
require(statusListEntry.statusPurpose == statusPurpose.toString().lowercase()) { "Status purpose mismatch" }

if (!duplicateSet.add(statusListEntry.statusListIndex)) {
throw IllegalArgumentException("duplicate entry found with index: ${statusListEntry.statusListIndex}")
throw IllegalArgumentException("Duplicate entry found with index: ${statusListEntry.statusListIndex}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw IllegalArgumentException("Duplicate entry found with index: ${statusListEntry.statusListIndex}")
"Duplicate entry found with index: ${statusListEntry.statusListIndex}"

}
}

Expand Down Expand Up @@ -286,15 +294,15 @@
val indexInt = index.toIntOrNull()

require(indexInt != null && indexInt >= 0) {
"invalid status list index: $index"
"Invalid status list index: $index"
}

require(indexInt < bitSetSize) {
throw IndexOutOfBoundsException("invalid status list index: $index, index is larger than the bitset size")
throw IndexOutOfBoundsException("Invalid status list index: $index, index is larger than the bitset size")
}

require(duplicateCheck.add(indexInt)) {
"duplicate status list index value found: $indexInt"
"Duplicate status list index value found: $indexInt"

Check warning on line 305 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L305

Added line #L305 was not covered by tests
}

bitSet.set(indexInt)
Expand All @@ -320,7 +328,7 @@
try {
decoded = Base64.getDecoder().decode(compressedBitstring)
} catch (e: Exception) {
throw RuntimeException("decoding compressed bitstring", e)
throw RuntimeException("Decoding compressed bitstring", e)

Check warning on line 331 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L331

Added line #L331 was not covered by tests
}

val bitstringInputStream = ByteArrayInputStream(decoded)
Expand All @@ -329,7 +337,7 @@
try {
GZIPInputStream(bitstringInputStream).use { it.copyTo(byteArrayOutputStream) }
} catch (e: Exception) {
throw RuntimeException("unzipping status list bitstring using GZIP", e)
throw RuntimeException("Unzipping status list bitstring using GZIP", e)

Check warning on line 340 in credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt#L340

Added line #L340 was not covered by tests
}

val unzipped = byteArrayOutputStream.toByteArray()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
package web5.sdk.credentials

import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.fullPath
import io.ktor.http.headersOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.assertThrows
import web5.sdk.crypto.InMemoryKeyManager
import web5.sdk.dids.DidKey
import java.io.File
import java.io.OutputStream
import java.net.URI
import kotlin.test.Test
import kotlin.test.assertEquals
Expand All @@ -18,7 +21,6 @@ import kotlin.test.assertNotNull
import kotlin.test.assertTrue

class StatusListCredentialTest {

@Test
fun `should parse valid VerifiableCredential from specification example`() {
val specExampleRevocableVcText = File("src/test/testdata/revocableVc.json").readText()
Expand Down Expand Up @@ -217,7 +219,7 @@ class StatusListCredentialTest {

assertTrue(
exception
.message!!.contains("duplicate entry found with index: 123")
.message!!.contains("Duplicate entry found with index: 123")
)
}

Expand Down Expand Up @@ -252,7 +254,7 @@ class StatusListCredentialTest {

assertTrue(
exception
.message!!.contains("invalid status list index: -1")
.message!!.contains("Invalid status list index: -1")
)
}

Expand Down Expand Up @@ -287,7 +289,39 @@ class StatusListCredentialTest {

assertTrue(
exception
.message!!.contains("invalid status list index: ${Int.MAX_VALUE}, index is larger than the bitset size")
.message!!.contains("Invalid status list index: ${Int.MAX_VALUE}, index is larger than the bitset size")
)
}

@Test
fun `should fail when generating StatusListCredential invalid issuer uri`() {
val exception = assertThrows<Exception> {
StatusListCredential.create(
"revocation-id",
"invalid uri",
StatusPurpose.REVOCATION,
listOf())
}

assertTrue(
exception
.message!!.contains("Issuer is not a valid URI")
)
}

@Test
fun `should fail when generating StatusListCredential invalid statusListCredId uri`() {
val exception = assertThrows<Exception> {
StatusListCredential.create(
"invalid slc id",
"did:example:123",
StatusPurpose.REVOCATION,
listOf())
}

assertTrue(
exception
.message!!.contains("Status list credential id is not a valid URI")
)
}

Expand Down Expand Up @@ -361,7 +395,7 @@ class StatusListCredentialTest {
}

@Test
fun `should asynchronously validate if a credential is in the status list using a mock HTTP client`() = runBlocking {
fun `should asynchronously validate if a credential is in the status list using a mock HTTP client`() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain the "asynchronously" here @nitro-neal ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

has to make an http GET to download the status list credential (vs passing in the status list credential object)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was also confused by this. Asynchronously, to me, means that you would need to explicitly wait until the http GET downloads the credential. That doesn't seem to be what the test is doing. Furthermore, that implementation has a runBlocking bit, which actually makes the async call be blocking.

My suggestion is to rename to should validate if a credential is in the status list using a mock HTTP client

val keyManager = InMemoryKeyManager()
val issuerDid = DidKey.create(keyManager)
val holderDid = DidKey.create(keyManager)
Expand Down Expand Up @@ -428,4 +462,118 @@ class StatusListCredentialTest {
val revoked2 = StatusListCredential.validateCredentialInStatusList(vc2, mockedHttpClient)
assertFalse(revoked2)
}

@Test
fun `should asynchronously validate if a credential is in the status list using default HTTP client`() {
// Create and start a server within the test
val server = HttpServer.create(java.net.InetSocketAddress(1234), 0)

val keyManager = InMemoryKeyManager()
val issuerDid = DidKey.create(keyManager)
val holderDid = DidKey.create(keyManager)

val credentialStatus1 = StatusList2021Entry.builder()
.id(URI.create("cred-with-status-id"))
.statusPurpose("revocation")
.statusListIndex("123")
.statusListCredential(URI.create("http://localhost:1234"))
.build()

val credWithCredStatus1 = VerifiableCredential.create(
type = "StreetCred",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = StreetCredibility(localRespect = "high", legit = true),
credentialStatus = credentialStatus1
)

val statusListCredential1 = StatusListCredential.create(
"http://localhost:1234",
issuerDid.uri,
StatusPurpose.REVOCATION,
listOf(credWithCredStatus1)
)

val signedStatusListCredential = statusListCredential1.sign(issuerDid)

server.createContext("/", object : HttpHandler {
override fun handle(t: HttpExchange) {
val response = signedStatusListCredential.toByteArray()
t.sendResponseHeaders(200, response.size.toLong())
val os: OutputStream = t.responseBody
os.write(response)
os.close()
}
})

try {
server.executor = null
server.start()

val revoked = StatusListCredential.validateCredentialInStatusList(credWithCredStatus1)
assertEquals(true, revoked)
} finally {
server.stop(0)
}
}

@Test
fun `should 404 because wrong address for status list credential status list using default HTTP client`() {
// Create and start a server within the test
val server = HttpServer.create(java.net.InetSocketAddress(1234), 0)

val keyManager = InMemoryKeyManager()
val issuerDid = DidKey.create(keyManager)
val holderDid = DidKey.create(keyManager)

val credentialStatus1 = StatusList2021Entry.builder()
.id(URI.create("cred-with-status-id"))
.statusPurpose("revocation")
.statusListIndex("123")
.statusListCredential(URI.create("http://localhost:4321"))
.build()

val credWithCredStatus1 = VerifiableCredential.create(
type = "StreetCred",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = StreetCredibility(localRespect = "high", legit = true),
credentialStatus = credentialStatus1
)

val statusListCredential1 = StatusListCredential.create(
"http://localhost:4321",
issuerDid.uri,
StatusPurpose.REVOCATION,
listOf(credWithCredStatus1)
)

val signedStatusListCredential = statusListCredential1.sign(issuerDid)

server.createContext("/", object : HttpHandler {
override fun handle(t: HttpExchange) {
val response = signedStatusListCredential.toByteArray()
t.sendResponseHeaders(200, response.size.toLong())
val os: OutputStream = t.responseBody
os.write(response)
os.close()
}
})

try {
server.executor = null
server.start()

val exception = assertThrows<Exception> {
StatusListCredential.validateCredentialInStatusList(credWithCredStatus1)
}

assertTrue(
exception
.message!!.contains("Failed to retrieve VerifiableCredentialType from http://localhost:4321")
)
} finally {
server.stop(0)
}
}
}
Loading