diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt b/credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt index 578881d5e..ca6665eb7 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/StatusListCredential.kt @@ -1,6 +1,5 @@ package web5.sdk.credentials -import com.danubetech.verifiablecredentials.CredentialSubject import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -13,6 +12,14 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess import io.ktor.serialization.jackson.jackson import kotlinx.coroutines.runBlocking +import web5.sdk.credentials.model.CredentialSubject +import web5.sdk.credentials.model.DEFAULT_STATUS_LIST_2021_ENTRY_TYPE +import web5.sdk.credentials.model.DEFAULT_STATUS_LIST_2021_VC_TYPE +import web5.sdk.credentials.model.DEFAULT_STATUS_LIST_CONTEXT +import web5.sdk.credentials.model.DEFAULT_VC_CONTEXT +import web5.sdk.credentials.model.DEFAULT_VC_TYPE +import web5.sdk.credentials.model.StatusList2021Entry +import web5.sdk.credentials.model.VcDataModel import web5.sdk.dids.DidResolvers import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -23,12 +30,6 @@ import java.util.Date import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream -/** - * Type alias representing the danubetech Status List 2021 Entry data model. - * This typealias simplifies the use of the [com.danubetech.verifiablecredentials.credentialstatus.StatusList2021Entry] class. - */ -public typealias StatusList2021Entry = com.danubetech.verifiablecredentials.credentialstatus.StatusList2021Entry - /** * Status purpose of a status list credential or a credential with a credential status. */ @@ -47,6 +48,11 @@ private const val ENCODED_LIST: String = "encodedList" */ private const val STATUS_PURPOSE: String = "statusPurpose" +/** + * The JSON property key for a type. + */ +private const val TYPE: String = "type" + /** * `StatusListCredential` represents a digitally verifiable status list credential according to the * [W3C Verifiable Credentials Status List v2021](https://www.w3.org/TR/vc-status-list/). @@ -104,19 +110,21 @@ public object StatusListCredential { throw IllegalArgumentException("issuer: $issuer not resolvable", e) } - val claims = mapOf(STATUS_PURPOSE to statusPurpose.toString().lowercase(), ENCODED_LIST to bitString) - val credSubject = CredentialSubject.builder() + val claims = mapOf(TYPE to DEFAULT_STATUS_LIST_2021_ENTRY_TYPE, + STATUS_PURPOSE to statusPurpose.toString().lowercase(), + ENCODED_LIST to bitString) + + val credSubject = CredentialSubject.Builder() .id(URI.create(statusListCredentialId)) - .type("StatusList2021") - .claims(claims) + .additionalClaims(claims) .build() - val vcDataModel = VcDataModel.builder() + val vcDataModel = VcDataModel.Builder() .id(URI.create(statusListCredentialId)) + .context(mutableListOf(URI.create(DEFAULT_VC_CONTEXT), URI.create(DEFAULT_STATUS_LIST_CONTEXT))) + .type(mutableListOf(DEFAULT_VC_TYPE, DEFAULT_STATUS_LIST_2021_VC_TYPE)) .issuer(URI.create(issuer)) .issuanceDate(Date()) - .context(URI.create("https://w3id.org/vc/status-list/2021/v1")) - .type("StatusList2021Credential") .credentialSubject(credSubject) .build() @@ -142,10 +150,11 @@ public object StatusListCredential { statusListCredential: VerifiableCredential ): Boolean { val statusListEntryValue: StatusList2021Entry = - StatusList2021Entry.fromJsonObject(credentialToValidate.vcDataModel.credentialStatus.jsonObject) + StatusList2021Entry.fromJsonObject(credentialToValidate.vcDataModel.credentialStatus!!.toJson()) - val statusListCredStatusPurpose: String? = - statusListCredential.vcDataModel.credentialSubject.jsonObject[STATUS_PURPOSE] as? String? + val credentialSubject = statusListCredential.vcDataModel.credentialSubject + + val statusListCredStatusPurpose: String? = credentialSubject.additionalClaims[STATUS_PURPOSE] as? String? require(statusListEntryValue.statusPurpose != null) { "Status purpose in the credential to validate is null" @@ -160,7 +169,7 @@ public object StatusListCredential { } val compressedBitstring: String? = - statusListCredential.vcDataModel.credentialSubject.jsonObject[ENCODED_LIST] as? String? + credentialSubject.additionalClaims[ENCODED_LIST] as? String? require(!compressedBitstring.isNullOrEmpty()) { "Compressed bitstring is null or empty" @@ -203,7 +212,7 @@ public object StatusListCredential { try { val statusListEntryValue: StatusList2021Entry = - StatusList2021Entry.fromJsonObject(credentialToValidate.vcDataModel.credentialStatus.jsonObject) + StatusList2021Entry.fromJsonObject(credentialToValidate.vcDataModel.credentialStatus!!.toJson()) val statusListCredential = client.fetchStatusListCredential(statusListEntryValue.statusListCredential.toString()) @@ -261,9 +270,11 @@ public object StatusListCredential { requireNotNull(vc.vcDataModel.credentialStatus) { "no credential status found in credential" } val statusListEntry: StatusList2021Entry = - StatusList2021Entry.fromJsonObject(vc.vcDataModel.credentialStatus.jsonObject) + StatusList2021Entry.fromJsonObject(vc.vcDataModel.credentialStatus.toJson()) - 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}") diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt index f987bd374..8140d42cf 100644 --- a/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt +++ b/credentials/src/main/kotlin/web5/sdk/credentials/VerifiableCredential.kt @@ -1,7 +1,5 @@ package web5.sdk.credentials -import com.danubetech.verifiablecredentials.CredentialSubject -import com.danubetech.verifiablecredentials.credentialstatus.CredentialStatus import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.JsonNode @@ -13,6 +11,12 @@ import com.nfeld.jsonpathkt.extension.read import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.JWTParser import com.nimbusds.jwt.SignedJWT +import web5.sdk.credentials.model.CredentialSubject +import web5.sdk.credentials.model.DEFAULT_STATUS_LIST_CONTEXT +import web5.sdk.credentials.model.DEFAULT_VC_CONTEXT +import web5.sdk.credentials.model.DEFAULT_VC_TYPE +import web5.sdk.credentials.model.StatusList2021Entry +import web5.sdk.credentials.model.VcDataModel import web5.sdk.credentials.util.JwtUtil import web5.sdk.dids.Did import java.net.URI @@ -20,12 +24,6 @@ import java.security.SignatureException import java.util.Date import java.util.UUID -/** - * Type alias representing the danubetech Verifiable Credential data model. - * This typealias simplifies the use of the [com.danubetech.verifiablecredentials.VerifiableCredential] class. - */ -public typealias VcDataModel = com.danubetech.verifiablecredentials.VerifiableCredential - /** * `VerifiableCredential` represents a digitally verifiable credential according to the * [W3C Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/). @@ -39,7 +37,7 @@ public typealias VcDataModel = com.danubetech.verifiablecredentials.VerifiableCr public class VerifiableCredential internal constructor(public val vcDataModel: VcDataModel) { public val type: String - get() = vcDataModel.types.last() + get() = vcDataModel.type.last() public val issuer: String get() = vcDataModel.issuer.toString() @@ -123,7 +121,7 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V issuer: String, subject: String, data: T, - credentialStatus: CredentialStatus? = null, + credentialStatus: StatusList2021Entry? = null, issuanceDate: Date = Date(), expirationDate: Date? = null ): VerifiableCredential { @@ -134,24 +132,25 @@ public class VerifiableCredential internal constructor(public val vcDataModel: V false -> throw IllegalArgumentException("expected data to be parseable into a JSON object") } - val credentialSubject = CredentialSubject.builder() + val credentialSubject = CredentialSubject.Builder() .id(URI.create(subject)) - .claims(mapData) + .additionalClaims(mapData) .build() - val vcDataModel = VcDataModel.builder() - .type(type) + val contexts = mutableListOf(URI.create(DEFAULT_VC_CONTEXT)) + if (credentialStatus != null) { + contexts.add(URI.create(DEFAULT_STATUS_LIST_CONTEXT)) + } + + val vcDataModel = VcDataModel.Builder() .id(URI.create("urn:uuid:${UUID.randomUUID()}")) + .context(contexts) + .type(mutableListOf(DEFAULT_VC_TYPE, type)) .issuer(URI.create(issuer)) .issuanceDate(issuanceDate) - .apply { expirationDate?.let { expirationDate(it) } } + .expirationDate(expirationDate) .credentialSubject(credentialSubject) - .apply { - credentialStatus?.let { - credentialStatus(it) - context(URI.create("https://w3id.org/vc/status-list/2021/v1")) - } - } + .credentialStatus(credentialStatus) .build() // This should be a no-op just to make sure we've set all the correct fields. diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/model/StatusList2021Entry.kt b/credentials/src/main/kotlin/web5/sdk/credentials/model/StatusList2021Entry.kt new file mode 100644 index 000000000..0ff3cd7fb --- /dev/null +++ b/credentials/src/main/kotlin/web5/sdk/credentials/model/StatusList2021Entry.kt @@ -0,0 +1,134 @@ +package web5.sdk.credentials.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import java.net.URI + +public const val DEFAULT_STATUS_LIST_2021_VC_TYPE: String = "StatusList2021" +public const val DEFAULT_STATUS_LIST_2021_ENTRY_TYPE: String = "StatusList2021Entry" +public const val DEFAULT_STATUS_LIST_CONTEXT: String = "https://w3id.org/vc/status-list/2021/v1" +private fun getObjectMapper(): ObjectMapper = jacksonObjectMapper().apply { + registerKotlinModule() + setSerializationInclusion(JsonInclude.Include.NON_NULL) +} + +/** + * The [StatusList2021Entry] instance representing the core data model of a bitstring status list entry. + * + * @see [Credential Status List](https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/) + */ +public class StatusList2021Entry( + public val id: URI, + public val type: String, + public val statusListIndex: String, + public val statusListCredential: URI, + public val statusPurpose: String, +) { + /** + * Builder class for creating [StatusList2021Entry] instances. + */ + public class Builder { + private lateinit var id: URI + private var type: String = DEFAULT_STATUS_LIST_2021_ENTRY_TYPE + private lateinit var statusListIndex: String + private lateinit var statusListCredential: URI + private lateinit var statusPurpose: String + + /** + * Sets the ID for the credential status list entry. + * @param id The unique identifier of the credential status list entry. + * @return Returns this builder to allow for chaining. + */ + public fun id(id: URI): Builder = apply { this.id = id } + + /** + * Sets the type for the credential status list entry. + * @param type The type of the credential status list entry. + * @return Returns this builder to allow for chaining. + */ + public fun type(type: String): Builder = apply { this.type = type } + + /** + * Sets the status list index for the credential status list entry. + * @param statusListIndex The status list index of the credential status list entry. + * @return Returns this builder to allow for chaining. + */ + public fun statusListIndex(statusListIndex: String): Builder = apply { this.statusListIndex = statusListIndex } + + /** + * Sets the status list credential for the credential status list entry. + * @param statusListCredential The status list credential of the credential status list entry. + * @return Returns this builder to allow for chaining. + */ + public fun statusListCredential(statusListCredential: URI): Builder = + apply { this.statusListCredential = statusListCredential } + + /** + * Sets the status purpose for the credential status list entry. + * @param statusPurpose The status purpose of the credential status list entry. + * @return Returns this builder to allow for chaining. + */ + public fun statusPurpose(statusPurpose: String): Builder = apply { this.statusPurpose = statusPurpose } + + /** + * Builds and returns the [StatusList2021Entry] object. + * @return The constructed [StatusList2021Entry] object. + * @throws IllegalStateException If any required fields are not set. + */ + public fun build(): StatusList2021Entry { + require(id.toString().isNotBlank()) { "Id cannot be blank" } + require(statusListIndex.isNotBlank()) { "StatusListIndex cannot be blank" } + require(statusListCredential.toString().isNotBlank()) { "StatusListCredential cannot be blank" } + require(statusPurpose.isNotBlank()) { "StatusPurpose cannot be blank" } + + return StatusList2021Entry( + id = id, + type = type, + statusListIndex = statusListIndex, + statusListCredential = statusListCredential, + statusPurpose = statusPurpose + ) + } + } + + /** + * Converts the [StatusList2021Entry] instance to a JSON string. + * + * @return A JSON string representation of the [StatusList2021Entry] instance. + */ + public fun toJson(): String = getObjectMapper().writeValueAsString(this) + + /** + * Converts the [StatusList2021Entry] instance into a Map representation. + * + * @return A Map containing key-value pairs representing the properties of the [StatusList2021Entry] instance. + */ + public fun toMap(): Map = + getObjectMapper().readValue(this.toJson(), object : TypeReference>() {}) + + public companion object { + /** + * Parses a JSON string to create an instance of [StatusList2021Entry]. + * + * @param jsonString The JSON string representation of a [StatusList2021Entry]. + * @return An instance of [StatusList2021Entry]. + */ + public fun fromJsonObject(jsonString: String): StatusList2021Entry = + getObjectMapper().readValue(jsonString, StatusList2021Entry::class.java) + + /** + * Creates an instance of [StatusList2021Entry] from a map of its properties. + * + * @param map A map containing the properties of a [StatusList2021Entry]. + * @return An instance of [StatusList2021Entry]. + * @throws IllegalArgumentException If required properties are missing. + */ + public fun fromMap(map: Map): StatusList2021Entry { + val json = getObjectMapper().writeValueAsString(map) + return getObjectMapper().readValue(json, StatusList2021Entry::class.java) + } + } +} \ No newline at end of file diff --git a/credentials/src/main/kotlin/web5/sdk/credentials/model/VcDataModel.kt b/credentials/src/main/kotlin/web5/sdk/credentials/model/VcDataModel.kt new file mode 100644 index 000000000..fd7ee4f69 --- /dev/null +++ b/credentials/src/main/kotlin/web5/sdk/credentials/model/VcDataModel.kt @@ -0,0 +1,342 @@ +package web5.sdk.credentials.model + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import java.net.URI +import java.text.SimpleDateFormat +import java.util.Date +import java.util.TimeZone + +public const val DEFAULT_VC_CONTEXT: String = "https://www.w3.org/2018/credentials/v1" +public const val DEFAULT_VC_TYPE: String = "VerifiableCredential" + +/** + * Global date format used for formatting the issuance and expiration dates of credentials. + * The value of the issuanceDate property MUST be a string value of an [XMLSCHEMA11-2] combined date-time + */ +private val DATE_FORMAT: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").apply { + timeZone = TimeZone.getTimeZone("UTC") +} + +private class DateSerializer : JsonSerializer() { + override fun serialize(value: Date?, gen: JsonGenerator?, serializers: SerializerProvider?) { + gen?.writeString(value?.let { DATE_FORMAT.format(it) }) + } +} + +private class DateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Date { + return DATE_FORMAT.parse(p?.text ?: "") + } +} + +private class CredentialSubjectSerializer : JsonSerializer() { + override fun serialize(value: CredentialSubject, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeStartObject() + // Write the id field. If id is null, write an empty string; otherwise, write its string representation. + gen.writeStringField("id", value.id?.toString() ?: "") + value.additionalClaims.forEach { (key, claimValue) -> + gen.writeObjectField(key, claimValue) + } + gen.writeEndObject() + } +} + +private class CredentialSubjectDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): CredentialSubject { + val node: JsonNode = p.codec.readTree(p) + val idNode = node.get("id") + val id = idNode?.asText()?.takeIf { it.isNotBlank() }?.let { URI.create(it) } + + require(id.toString().isNotBlank()) {"Credential Subject id cannot be blank"} + + // Remove the "id" field and treat the rest as additionalClaims + val additionalClaims = node.fields().asSequence().filterNot { it.key == "id" } + .associate { it.key to it.value.asText() } // Assuming all values are stored as text + + return CredentialSubject.Builder() + .id(id!!) + .additionalClaims(additionalClaims) + .build() + } +} + +private fun getObjectMapper(): ObjectMapper { + return jacksonObjectMapper().apply { + registerKotlinModule() + setSerializationInclusion(JsonInclude.Include.NON_NULL) + + val dateModule = SimpleModule().apply { + addSerializer(Date::class.java, DateSerializer()) + addDeserializer(Date::class.java, DateDeserializer()) + addSerializer(CredentialSubject::class.java, CredentialSubjectSerializer()) + addDeserializer(CredentialSubject::class.java, CredentialSubjectDeserializer()) + } + registerModule(dateModule) + } +} + +/** + * The [VcDataModel] instance representing the core data model of a verifiable credential. + * + * @see {@link https://www.w3.org/TR/vc-data-model/#credentials | VC Data Model} + */ +public class VcDataModel( + public val id: URI?, + @JsonProperty("@context") + public val context: List, + public val type: List, + public val issuer: URI, + public val issuanceDate: Date, + public val expirationDate: Date?, + public val credentialSubject: CredentialSubject, + public val credentialSchema: CredentialSchema?, + public val credentialStatus: StatusList2021Entry? +) { + /** + * Builder class for creating [VcDataModel] instances. + */ + public class Builder { + private lateinit var id: URI + private var context: List = listOf() + private var type: List = listOf() + private lateinit var issuer: URI + private lateinit var issuanceDate: Date + private var expirationDate: Date? = null + private lateinit var credentialSubject: CredentialSubject + private var credentialSchema: CredentialSchema? = null + private var credentialStatus: StatusList2021Entry? = null + + /** + * Sets the ID URI for the [VcDataModel]. + * @param id The unique identifier URI of the credential. + * @return Returns this builder to allow for chaining. + */ + public fun id(id: URI): Builder = apply { this.id = id } + + /** + * Sets the context URIs for the [VcDataModel]. + * @param context A list of context URIs. + * @return Returns this builder to allow for chaining. + */ + public fun context(context: List): Builder = apply { this.context = context } + + /** + * Sets the type(s) for the [VcDataModel]. + * @param type A list of types. + * @return Returns this builder to allow for chaining. + */ + public fun type(type: List): Builder = apply { this.type = type } + + /** + * Sets the issuer URI for the [VcDataModel]. + * @param issuer The issuer URI of the credential. + * @return Returns this builder to allow for chaining. + */ + public fun issuer(issuer: URI): Builder = apply { this.issuer = issuer } + + /** + * Sets the issuance date for the [VcDataModel]. + * @param issuanceDate The date when the credential was issued. + * @return Returns this builder to allow for chaining. + */ + public fun issuanceDate(issuanceDate: Date): Builder = apply { this.issuanceDate = issuanceDate } + + /** + * Sets the expiration date for the [VcDataModel]. + * @param expirationDate The date when the credential expires. + * @return Returns this builder to allow for chaining. + */ + public fun expirationDate(expirationDate: Date?): Builder = apply { this.expirationDate = expirationDate } + + /** + * Sets the credential subject for the [VcDataModel]. + * @param credentialSubject The subject of the credential. + * @return Returns this builder to allow for chaining. + */ + public fun credentialSubject(credentialSubject: CredentialSubject): Builder = + apply { this.credentialSubject = credentialSubject } + + /** + * Sets the credential schema for the [VcDataModel]. + * @param credentialSchema The schema of the credential. + * @return Returns this builder to allow for chaining. + */ + public fun credentialSchema(credentialSchema: CredentialSchema?): Builder = + apply { this.credentialSchema = credentialSchema } + + /** + * Sets the credential status for the [VcDataModel]. + * @param credentialStatus The status of the credential. + * @return Returns this builder to allow for chaining. + */ + public fun credentialStatus(credentialStatus: StatusList2021Entry?): Builder = + apply { this.credentialStatus = credentialStatus } + + /** + * Builds and returns the [VcDataModel] object. + * @return The constructed [VcDataModel] object. + * @throws IllegalStateException If the issuer or issuance date are not set, or other validation fails. + */ + public fun build(): VcDataModel { + + require(context.contains(URI.create(DEFAULT_VC_CONTEXT))) { "context must include at least: $DEFAULT_VC_CONTEXT" } + require(id.toString().isNotBlank()) { "ID URI cannot be blank" } + require(type.contains(DEFAULT_VC_TYPE)) { "type must include at least: $DEFAULT_VC_TYPE" } + require(issuer.toString().isNotBlank()) { "Issuer URI cannot be blank" } + + if (expirationDate != null) { + require(issuanceDate.before(expirationDate)) { "Issuance date must be before expiration date" } + } + + return VcDataModel(id, context, type, issuer, issuanceDate, expirationDate, + credentialSubject, credentialSchema, credentialStatus) + } + } + + /** + * Converts the [VcDataModel] instance to a JSON string. + * + * @return A JSON string representation of the [VcDataModel] instance. + */ + public fun toJson(): String { + val mapper = getObjectMapper() + return mapper.writeValueAsString(this) + } + + /** + * Converts the [VcDataModel] instance into a Map representation. + * + * @return A Map containing key-value pairs representing the properties of the [VcDataModel] instance. + */ + public fun toMap(): Map { + val mapper = getObjectMapper() + val jsonString = mapper.writeValueAsString(this) + val typeRef = object : TypeReference>() {} + return mapper.readValue(jsonString, typeRef) + } + public companion object { + + /** + * Parses a JSON string to create an instance of [VcDataModel]. + * + * @param jsonString The JSON string representation of a [VcDataModel]. + * @return An instance of [VcDataModel]. + */ + public fun fromJsonObject(jsonString: String): VcDataModel { + val mapper = getObjectMapper() + return mapper.readValue(jsonString, object : TypeReference() {}) + } + + /** + * Builds a new [CredentialSubject] instance from a map of properties. + * + * @param map A map representing the [CredentialSubject]'s properties. + * @return An instance of [CredentialSubject] constructed from the provided map. + */ + public fun fromMap(map: Map): VcDataModel { + val mapper = getObjectMapper() + val json = mapper.writeValueAsString(map) + return mapper.readValue(json, VcDataModel::class.java) + } + } +} + +/** + * The [CredentialSubject] represents the value of the credentialSubject property as a set of objects containing + * properties related to the subject of the verifiable credential. + */ +public class CredentialSubject( + public val id: URI, + public val additionalClaims: Map +) { + /** + * Builder class for creating [CredentialSubject] instances. + */ + public class Builder { + private lateinit var id: URI + private var additionalClaims: Map = emptyMap() + + /** + * Sets the ID URI for the credential subject. + * @param id The unique identifier URI of the credential subject. + * @return Returns this builder to allow for chaining. + */ + public fun id(id: URI): Builder = apply { this.id = id } + + /** + * Sets the additional claims for the credential subject. + * Additional claims provide more information about the credential subject. + * @param additionalClaims A map of claim names to claim values. + * @return Returns this builder to allow for chaining. + */ + public fun additionalClaims(additionalClaims: Map): Builder = + apply { this.additionalClaims = additionalClaims } + + /** + * Builds and returns the [CredentialSubject] object. + * @return The constructed [CredentialSubject] object. + * @throws IllegalStateException If the ID URI is not valid. + */ + public fun build(): CredentialSubject { + require(id.toString().isNotBlank()) { "ID URI cannot be blank" } + + return CredentialSubject(id, additionalClaims) + } + } +} + + +/** + * The [CredentialSchema] Represents the schema defining the structure of a credential. + */ +public class CredentialSchema( + public val id: String, + public val type: String +) { + /** + * Builder class for creating [CredentialSchema] instances. + */ + public class Builder { + private lateinit var id: String + private lateinit var type: String + + /** + * Sets the ID for the credential schema. + * @param id The unique identifier of the credential schema. + * @return Returns this builder to allow for chaining. + */ + public fun id(id: String): Builder = apply { this.id = id } + + /** + * Sets the type for the credential schema. + * @param type The type of the credential schema. + * @return Returns this builder to allow for chaining. + */ + public fun type(type: String): Builder = apply { this.type = type } + + /** + * Builds and returns the [CredentialSchema] object. + * @return The constructed [CredentialSchema] object. + * @throws IllegalStateException If the id is not set. + */ + public fun build(): CredentialSchema { + require(id.toString().isNotBlank()) { "ID cannot be blank" } + require(type == "JsonSchema") { "Type must be: JsonSchema" } + + return CredentialSchema(id, type) + } + } +} diff --git a/credentials/src/test/kotlin/web5/sdk/credentials/StatusListCredentialTest.kt b/credentials/src/test/kotlin/web5/sdk/credentials/StatusListCredentialTest.kt index 923045c73..927dac625 100644 --- a/credentials/src/test/kotlin/web5/sdk/credentials/StatusListCredentialTest.kt +++ b/credentials/src/test/kotlin/web5/sdk/credentials/StatusListCredentialTest.kt @@ -8,6 +8,7 @@ import io.ktor.http.fullPath import io.ktor.http.headersOf import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.assertThrows +import web5.sdk.credentials.model.StatusList2021Entry import web5.sdk.crypto.InMemoryKeyManager import web5.sdk.dids.methods.key.DidKey import java.io.File @@ -32,13 +33,13 @@ class StatusListCredentialTest { assertEquals(specExampleRevocableVc.issuer, "did:example:12345") assertNotNull(specExampleRevocableVc.vcDataModel.credentialStatus) assertEquals(specExampleRevocableVc.subject, "did:example:6789") - assertEquals(specExampleRevocableVc.vcDataModel.credentialSubject.type.toString(), "Person") + assertEquals(specExampleRevocableVc.vcDataModel.credentialSubject.additionalClaims["type"], "Person") val credentialStatus: StatusList2021Entry = - StatusList2021Entry.fromJsonObject(specExampleRevocableVc.vcDataModel.credentialStatus.jsonObject) + StatusList2021Entry.fromJsonObject(specExampleRevocableVc.vcDataModel.credentialStatus!!.toJson()) assertEquals(credentialStatus.id.toString(), "https://example.com/credentials/status/3#94567") - assertEquals(credentialStatus.type.toString(), "BitStringStatusListEntry") + assertEquals(credentialStatus.toMap()["type"].toString(), "StatusList2021Entry") assertEquals(credentialStatus.statusPurpose.toString(), StatusPurpose.REVOCATION.toString().lowercase()) assertEquals(credentialStatus.statusListIndex, "94567") assertEquals(credentialStatus.statusListCredential.toString(), "https://example.com/credentials/status/3") @@ -50,13 +51,14 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus = StatusList2021Entry.builder() + val credentialStatus = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") .statusListCredential(URI.create("https://example.com/credentials/status/3")) .build() + val credWithCredStatus = VerifiableCredential.create( type = "StreetCred", issuer = issuerDid.uri, @@ -66,7 +68,7 @@ class StatusListCredentialTest { ) assertTrue( - credWithCredStatus.vcDataModel.contexts.containsAll( + credWithCredStatus.vcDataModel.context.containsAll( listOf( URI.create("https://www.w3.org/2018/credentials/v1"), URI.create("https://w3id.org/vc/status-list/2021/v1") @@ -78,13 +80,14 @@ class StatusListCredentialTest { assertEquals(credWithCredStatus.issuer, issuerDid.uri) assertNotNull(credWithCredStatus.vcDataModel.issuanceDate) assertNotNull(credWithCredStatus.vcDataModel.credentialStatus) - assertEquals(credWithCredStatus.vcDataModel.credentialSubject.claims.get("localRespect"), "high") + assertEquals(credWithCredStatus.vcDataModel.credentialSubject.additionalClaims["localRespect"], "high") val credStatus: StatusList2021Entry = - StatusList2021Entry.fromJsonObject(credWithCredStatus.vcDataModel.credentialStatus.jsonObject) + StatusList2021Entry.fromJsonObject(credWithCredStatus.vcDataModel.credentialStatus!!.toJson()) assertEquals(credStatus.id.toString(), "cred-with-status-id") - assertEquals(credStatus.type.toString(), "StatusList2021Entry") + assertEquals(credStatus.toMap()["type"], "StatusList2021Entry" + + "") assertEquals(credStatus.statusPurpose.toString(), StatusPurpose.REVOCATION.toString().lowercase()) assertEquals(credStatus.statusListIndex, "123") assertEquals(credStatus.statusListCredential.toString(), "https://example.com/credentials/status/3") @@ -96,7 +99,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") @@ -111,7 +114,7 @@ class StatusListCredentialTest { credentialStatus = credentialStatus1 ) - val credentialStatus2 = StatusList2021Entry.builder() + val credentialStatus2 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("124") @@ -135,7 +138,7 @@ class StatusListCredentialTest { assertNotNull(statusListCredential) assertTrue( - statusListCredential.vcDataModel.contexts.containsAll( + statusListCredential.vcDataModel.context.containsAll( listOf( URI.create("https://www.w3.org/2018/credentials/v1"), URI.create("https://w3id.org/vc/status-list/2021/v1") @@ -143,18 +146,19 @@ class StatusListCredentialTest { ) ) assertTrue( - statusListCredential.vcDataModel.types.containsAll( + statusListCredential.vcDataModel.type.containsAll( listOf( "VerifiableCredential", - "StatusList2021Credential" + "StatusList2021" ) ) ) assertEquals(statusListCredential.subject, "revocation-id") - assertEquals(statusListCredential.vcDataModel.credentialSubject.type, "StatusList2021") + assertEquals(statusListCredential.vcDataModel.credentialSubject + .additionalClaims["type"], "StatusList2021Entry") assertEquals( "revocation", - statusListCredential.vcDataModel.credentialSubject.jsonObject["statusPurpose"] as? String? + statusListCredential.vcDataModel.credentialSubject.additionalClaims["statusPurpose"] as? String? ) // TODO: Check encoding across other sdks and spec - https://github.com/TBD54566975/web5-kt/issues/97 @@ -171,7 +175,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") @@ -186,7 +190,7 @@ class StatusListCredentialTest { credentialStatus = credentialStatus1 ) - val credentialStatus2 = StatusList2021Entry.builder() + val credentialStatus2 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") @@ -222,13 +226,14 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("-1") .statusListCredential(URI.create("https://example.com/credentials/status/3")) .build() + val vc1 = VerifiableCredential.create( type = "StreetCred", issuer = issuerDid.uri, @@ -258,7 +263,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex(Int.MAX_VALUE.toString()) @@ -294,7 +299,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") @@ -309,13 +314,14 @@ class StatusListCredentialTest { credentialStatus = credentialStatus1 ) - val credentialStatus2 = StatusList2021Entry.builder() + val credentialStatus2 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("124") .statusListCredential(URI.create("https://example.com/credentials/status/3")) .build() + val vc2 = VerifiableCredential.create( type = "StreetCred", issuer = issuerDid.uri, @@ -324,7 +330,7 @@ class StatusListCredentialTest { credentialStatus2 ) - val credentialStatus3 = StatusList2021Entry.builder() + val credentialStatus3 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("125") @@ -363,7 +369,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") @@ -378,7 +384,7 @@ class StatusListCredentialTest { credentialStatus = credentialStatus1 ) - val credentialStatus2 = StatusList2021Entry.builder() + val credentialStatus2 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("124") @@ -433,7 +439,7 @@ class StatusListCredentialTest { val issuerDid = DidKey.create(keyManager) val holderDid = DidKey.create(keyManager) - val credentialStatus1 = StatusList2021Entry.builder() + val credentialStatus1 = StatusList2021Entry.Builder() .id(URI.create("cred-with-status-id")) .statusPurpose("revocation") .statusListIndex("123") diff --git a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt index 8f5e29533..f91b99f1d 100644 --- a/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt +++ b/credentials/src/test/kotlin/web5/sdk/credentials/VerifiableCredentialTest.kt @@ -13,6 +13,9 @@ import com.nimbusds.jwt.SignedJWT import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow +import web5.sdk.credentials.model.CredentialSchema +import web5.sdk.credentials.model.CredentialSubject +import web5.sdk.credentials.model.VcDataModel import web5.sdk.crypto.AlgorithmId import web5.sdk.crypto.AwsKeyManager import web5.sdk.crypto.InMemoryKeyManager @@ -24,12 +27,17 @@ import web5.sdk.dids.methods.dht.DidDht import web5.sdk.dids.methods.key.DidKey import web5.sdk.testing.TestVectors import java.io.File +import java.net.URI import java.security.SignatureException import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.TimeZone import kotlin.test.Ignore import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNotNull +import kotlin.test.assertTrue data class StreetCredibility(val localRespect: String, val legit: Boolean) class VerifiableCredentialTest { @@ -225,6 +233,158 @@ class VerifiableCredentialTest { assertEquals(vc.issuer, parsedVc.issuer) assertEquals(vc.subject, parsedVc.subject) } + + @Test + fun `vcDataModel should fail with empty id`() { + var exception = assertThrows(IllegalArgumentException::class.java) { + VcDataModel.Builder() + .id(URI.create("")) + .context(mutableListOf(URI.create("https://www.w3.org/2018/credentials/v1"))) + .type(mutableListOf("VerifiableCredential")) + .issuer(URI.create("did:example:issuer")) + .issuanceDate(Date()) + .credentialSubject( + CredentialSubject.Builder() + .id(URI.create("did:example:subject")) + .additionalClaims(mapOf("claimKey" to "claimValue")) + .build() + ) + .build() + } + + assertTrue(exception.message!!.contains("ID URI cannot be blank")) + } + + @Test + fun `vcDataModel should fail with empty issuer`() { + var exception = assertThrows(IllegalArgumentException::class.java) { + VcDataModel.Builder() + .id(URI.create("123")) + .context(mutableListOf(URI.create("https://www.w3.org/2018/credentials/v1"))) + .type(mutableListOf("VerifiableCredential")) + .issuer(URI.create("")) + .issuanceDate(Date()) + .credentialSubject( + CredentialSubject.Builder() + .id(URI.create("did:example:subject")) + .additionalClaims(mapOf("claimKey" to "claimValue")) + .build() + ) + .build() + } + + assertTrue(exception.message!!.contains("Issuer URI cannot be blank")) + } + + @Test + fun `vcDataModel should fail with issuance date before expiration date`() { + var exception = assertThrows(IllegalArgumentException::class.java) { + VcDataModel.Builder() + .id(URI.create("123")) + .context(mutableListOf(URI.create("https://www.w3.org/2018/credentials/v1"))) + .type(mutableListOf("VerifiableCredential")) + .issuer(URI.create("did:example:issuer")) + .issuanceDate(Date()) + .expirationDate(Date(Date().time - 100)) + .credentialSubject( + CredentialSubject.Builder() + .id(URI.create("did:example:subject")) + .additionalClaims(mapOf("claimKey" to "claimValue")) + .build() + ) + .build() + } + + assertTrue(exception.message!!.contains("Issuance date must be before expiration date")) + } + + @Test + fun `vcDataModel should add default context`() { + + val exception = + assertThrows(IllegalArgumentException::class.java) { + VcDataModel.Builder() + .id(URI.create("123")) + .context(mutableListOf()) + .type(mutableListOf()) + .issuer(URI.create("http://example.com/issuer")) + .issuanceDate(Date()) + .credentialSubject( + CredentialSubject.Builder() + .id(URI.create("http://example.com/subject")) + .additionalClaims(mapOf("claimKey" to "claimValue")) + .build() + ) + .build() + } + + assertTrue(exception.message!!.contains("context must include at least: https://www.w3.org/2018/credentials/v1" )) + } + + @Test + fun `vcDataModel should add default type`() { + + val exception = + assertThrows(IllegalArgumentException::class.java) { + VcDataModel.Builder() + .id(URI.create("123")) + .context(mutableListOf(URI.create("https://www.w3.org/2018/credentials/v1"))) + .type(mutableListOf()) + .issuer(URI.create("http://example.com/issuer")) + .issuanceDate(Date()) + .credentialSubject( + CredentialSubject.Builder() + .id(URI.create("http://example.com/subject")) + .additionalClaims(mapOf("claimKey" to "claimValue")) + .build() + ) + .build() + } + + assertTrue(exception.message!!.contains("type must include at least: VerifiableCredential" )) + } + + @Test + fun `vcDataModel fromJsonObject should correctly parse JSON into VcDataModel`() { + val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val issuanceDate = Date() + val json = """ + { + "id": "http://example.com/credential", + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "http://example.com/issuer", + "issuanceDate": "${dateFormat.format(issuanceDate)}", + "credentialSubject": { + "id": "http://example.com/subject", + "additionalClaims": {} + } + } + """.trimIndent() + + val vcDataModel = VcDataModel.fromJsonObject(json) + + assertEquals(URI("http://example.com/credential"), vcDataModel.id) + assertEquals(listOf(URI("https://www.w3.org/2018/credentials/v1")), vcDataModel.context) + assertEquals(listOf("VerifiableCredential"), vcDataModel.type) + assertEquals(URI("http://example.com/issuer"), vcDataModel.issuer) + assertEquals(dateFormat.format(issuanceDate), dateFormat.format(vcDataModel.issuanceDate)) + assertEquals(URI("http://example.com/subject"), vcDataModel.credentialSubject.id) + } + + @Test + fun `vcDataModel credentialSchema should fail with wrong type`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + CredentialSchema.Builder() + .id("did:example:123") + .type("otherType") + .build() + } + + assertTrue(exception.message!!.contains("Type must be: JsonSchema")) + } } class Web5TestVectorsCredentials { @@ -247,7 +407,6 @@ class Web5TestVectorsCredentials { val testVectors = mapper.readValue(File("../web5-spec/test-vectors/credentials/create.json"), typeRef) testVectors.vectors.filterNot { it.errors ?: false }.forEach { vector -> - println(vector.description) val vc = VerifiableCredential.fromJson(mapper.writeValueAsString(vector.input.credential)) val keyManager = InMemoryKeyManager() @@ -255,7 +414,10 @@ class Web5TestVectorsCredentials { val issuerDid = Did.load(vector.input.signerDidUri!!, keyManager) val vcJwt = vc.sign(issuerDid) - assertEquals(vector.output, vcJwt, vector.description) + val vectorOutputParsedVc = vector.output?.let { VerifiableCredential.parseJwt(it) } + val outputParsedVc = VerifiableCredential.parseJwt(vcJwt) + + assertEquals(vectorOutputParsedVc.toString(), outputParsedVc.toString()) } testVectors.vectors.filter { it.errors ?: false }.forEach { vector -> @@ -271,7 +433,6 @@ class Web5TestVectorsCredentials { val testVectors = mapper.readValue(File("../web5-spec/test-vectors/credentials/verify.json"), typeRef) testVectors.vectors.filterNot { it.errors ?: false }.forEach { vector -> - println(vector.description) assertDoesNotThrow { VerifiableCredential.verify(vector.input.vcJwt) } diff --git a/credentials/src/test/resources/revocable_vc.json b/credentials/src/test/resources/revocable_vc.json index 4c624b8bf..e03f8b99c 100644 --- a/credentials/src/test/resources/revocable_vc.json +++ b/credentials/src/test/resources/revocable_vc.json @@ -1,23 +1,23 @@ { - "@context": [ + "@context":[ "https://www.w3.org/2018/credentials/v1", - "https://www.w3.org/2018/credentials/examples/v1" + "https://w3id.org/vc/status-list/2021/v1" ], - "id": "https://example.com/credentials/23894672394", - "type": [ + "id":"https://example.com/credentials/23894672394", + "type":[ "VerifiableCredential" ], - "issuer": "did:example:12345", + "issuer":"did:example:12345", "issuanceDate": "2021-04-05T14:27:42Z", - "credentialStatus": { - "id": "https://example.com/credentials/status/3#94567", - "type": "BitStringStatusListEntry", - "statusPurpose": "revocation", - "statusListIndex": "94567", - "statusListCredential": "https://example.com/credentials/status/3" + "credentialStatus":{ + "id":"https://example.com/credentials/status/3#94567", + "type":"StatusList2021Entry", + "statusPurpose":"revocation", + "statusListIndex":"94567", + "statusListCredential":"https://example.com/credentials/status/3" }, - "credentialSubject": { - "id": "did:example:6789", - "type": "Person" + "credentialSubject":{ + "id":"did:example:6789", + "type":"Person" } } \ No newline at end of file