-
Notifications
You must be signed in to change notification settings - Fork 10
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 | ||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
val claims = mapOf(STATUS_PURPOSE to statusPurpose.toString().lowercase(), ENCODED_LIST to bitString) | ||||||
|
@@ -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 { | ||||||
|
@@ -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}" | ||||||
} | ||||||
|
||||||
try { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||
} | ||||||
} | ||||||
|
||||||
|
@@ -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}") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} | ||||||
} | ||||||
|
||||||
|
@@ -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" | ||||||
} | ||||||
|
||||||
bitSet.set(indexInt) | ||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
val bitstringInputStream = ByteArrayInputStream(decoded) | ||||||
|
@@ -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) | ||||||
} | ||||||
|
||||||
val unzipped = byteArrayOutputStream.toByteArray() | ||||||
|
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 | ||
|
@@ -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() | ||
|
@@ -217,7 +219,7 @@ class StatusListCredentialTest { | |
|
||
assertTrue( | ||
exception | ||
.message!!.contains("duplicate entry found with index: 123") | ||
.message!!.contains("Duplicate entry found with index: 123") | ||
) | ||
} | ||
|
||
|
@@ -252,7 +254,7 @@ class StatusListCredentialTest { | |
|
||
assertTrue( | ||
exception | ||
.message!!.contains("invalid status list index: -1") | ||
.message!!.contains("Invalid status list index: -1") | ||
) | ||
} | ||
|
||
|
@@ -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") | ||
) | ||
} | ||
|
||
|
@@ -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`() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you explain the "asynchronously" here @nitro-neal ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 My suggestion is to rename to |
||
val keyManager = InMemoryKeyManager() | ||
val issuerDid = DidKey.create(keyManager) | ||
val holderDid = DidKey.create(keyManager) | ||
|
@@ -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) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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?