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

feat: VC support for Array of credential Status #1383

Merged
merged 3 commits into from
Sep 30, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ sealed trait CredentialPayload {

def issuer: String | CredentialIssuer

def maybeCredentialStatus: Option[CredentialStatus]
def maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]]

def maybeRefreshService: Option[RefreshService]

Expand Down Expand Up @@ -145,7 +145,7 @@ case class JwtVc(
maybeValidFrom: Option[Instant],
maybeValidUntil: Option[Instant],
maybeIssuer: Option[String | CredentialIssuer],
maybeCredentialStatus: Option[CredentialStatus],
maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]],
maybeRefreshService: Option[RefreshService],
maybeEvidence: Option[Json],
maybeTermsOfUse: Option[Json]
Expand Down Expand Up @@ -182,7 +182,7 @@ case class W3cCredentialPayload(
maybeExpirationDate: Option[Instant],
override val maybeCredentialSchema: Option[CredentialSchema | List[CredentialSchema]],
override val credentialSubject: Json,
override val maybeCredentialStatus: Option[CredentialStatus],
override val maybeCredentialStatus: Option[CredentialStatus | List[CredentialStatus]],
override val maybeRefreshService: Option[RefreshService],
override val maybeEvidence: Option[Json],
override val maybeTermsOfUse: Option[Json],
Expand Down Expand Up @@ -239,6 +239,11 @@ object CredentialPayload {
("statusListCredential", credentialStatus.statusListCredential.asJson)
)

implicit val credentialStatusOrListEncoder: Encoder[CredentialStatus | List[CredentialStatus]] = Encoder.instance {
case status: CredentialStatus => Encoder[CredentialStatus].apply(status)
case statusList: List[CredentialStatus] => Encoder[List[CredentialStatus]].apply(statusList)
}

implicit val stringOrCredentialIssuerEncoder: Encoder[String | CredentialIssuer] = Encoder.instance {
case string: String => Encoder[String].apply(string)
case credentialIssuer: CredentialIssuer => Encoder[CredentialIssuer].apply(credentialIssuer)
Expand Down Expand Up @@ -383,6 +388,11 @@ object CredentialPayload {
.map(schema => schema: CredentialSchema | List[CredentialSchema])
.or(Decoder[List[CredentialSchema]].map(schema => schema: CredentialSchema | List[CredentialSchema]))

implicit val credentialStatusOrListDecoder: Decoder[CredentialStatus | List[CredentialStatus]] =
Decoder[CredentialStatus]
.map(status => status: CredentialStatus | List[CredentialStatus])
.or(Decoder[List[CredentialStatus]].map(status => status: CredentialStatus | List[CredentialStatus]))

implicit val w3cCredentialPayloadDecoder: Decoder[W3cCredentialPayload] =
(c: HCursor) =>
for {
Expand All @@ -404,7 +414,7 @@ object CredentialPayload {
.downField("credentialSchema")
.as[Option[CredentialSchema | List[CredentialSchema]]]
credentialSubject <- c.downField("credentialSubject").as[Json]
maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus]]
maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus | List[CredentialStatus]]]
maybeRefreshService <- c.downField("refreshService").as[Option[RefreshService]]
maybeEvidence <- c.downField("evidence").as[Option[Json]]
maybeTermsOfUse <- c.downField("termsOfUse").as[Option[Json]]
Expand Down Expand Up @@ -443,7 +453,7 @@ object CredentialPayload {
.downField("credentialSchema")
.as[Option[CredentialSchema | List[CredentialSchema]]]
credentialSubject <- c.downField("credentialSubject").as[Json]
maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus]]
maybeCredentialStatus <- c.downField("credentialStatus").as[Option[CredentialStatus | List[CredentialStatus]]]
maybeRefreshService <- c.downField("refreshService").as[Option[RefreshService]]
maybeEvidence <- c.downField("evidence").as[Option[Json]]
maybeTermsOfUse <- c.downField("termsOfUse").as[Option[Json]]
Expand Down Expand Up @@ -837,7 +847,7 @@ object JwtCredential {
} yield Validation.validateWith(signatureValidation, dateVerification, revocationVerification)((a, _, _) => a)
}

private def verifyRevocationStatusJwt(jwt: JWT)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = {
def verifyRevocationStatusJwt(jwt: JWT)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = {
val decodeJWT =
ZIO
.fromTry(JwtCirce.decodeRaw(jwt.value, options = JwtOptions(false, false, false)))
Expand All @@ -847,12 +857,19 @@ object JwtCredential {
decodedJWT <- decodeJWT
jwtCredentialPayload <- ZIO.fromEither(decode[JwtCredentialPayload](decodedJWT)).mapError(_.getMessage)
credentialStatus = jwtCredentialPayload.vc.maybeCredentialStatus
result = credentialStatus.fold(ZIO.succeed(Validation.unit))(status =>
CredentialVerification.verifyCredentialStatus(status)(uriResolver)
.map {
{
case status: CredentialStatus => List(status)
case statusList: List[CredentialStatus] => statusList
}
}
.getOrElse(List.empty)
results <- ZIO.collectAll(
credentialStatus.map(status => CredentialVerification.verifyCredentialStatus(status)(uriResolver))
)
result = Validation.validateAll(results).flatMap(_ => Validation.unit)
} yield result

res.flatten
res
}
}

Expand Down Expand Up @@ -927,11 +944,20 @@ object W3CCredential {
private def verifyRevocationStatusW3c(
w3cPayload: W3cVerifiableCredentialPayload,
)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = {
// If credential does not have credential status list, it does not support revocation
// and we assume revocation status is valid.
w3cPayload.payload.maybeCredentialStatus.fold(ZIO.succeed(Validation.unit))(status =>
CredentialVerification.verifyCredentialStatus(status)(uriResolver)
)
val credentialStatus = w3cPayload.payload.maybeCredentialStatus
.map {
{
case status: CredentialStatus => List(status)
case statusList: List[CredentialStatus] => statusList
}
}
.getOrElse(List.empty)
for {
results <- ZIO.collectAll(
credentialStatus.map(status => CredentialVerification.verifyCredentialStatus(status)(uriResolver))
)
result = Validation.validateAll(results).flatMap(_ => Validation.unit)
} yield result
}

def verify(w3cPayload: W3cVerifiableCredentialPayload, options: CredentialVerification.CredentialVerificationOptions)(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.circe.*
import io.circe.syntax.*
import org.hyperledger.identus.castor.core.model.did.{DID, VerificationRelationship}
import org.hyperledger.identus.pollux.vc.jwt.CredentialPayload.Implicits.*
import org.hyperledger.identus.pollux.vc.jwt.StatusPurpose.Revocation
import org.hyperledger.identus.shared.http.*
import zio.*
import zio.prelude.Validation
Expand Down Expand Up @@ -62,7 +63,11 @@ object JWTVerificationTest extends ZIOSpecDefault {
|}
|""".stripMargin

private def createJwtCredential(issuer: IssuerWithKey, issuerAsObject: Boolean = false): JWT = {
private def createJwtCredential(
issuer: IssuerWithKey,
issuerAsObject: Boolean = false,
credentialStatus: Option[CredentialStatus | List[CredentialStatus]] = None
): JWT = {
val validFrom = Instant.parse("2010-01-05T00:00:00Z") // ISSUANCE DATE
val jwtCredentialNbf = Instant.parse("2010-01-01T00:00:00Z") // ISSUANCE DATE
val validUntil = Instant.parse("2010-01-09T00:00:00Z") // EXPIRATION DATE
Expand All @@ -75,7 +80,7 @@ object JWTVerificationTest extends ZIOSpecDefault {
`type` = Set("VerifiableCredential", "UniversityDegreeCredential"),
maybeCredentialSchema = None,
credentialSubject = Json.obj("id" -> Json.fromString("1")),
maybeCredentialStatus = None,
maybeCredentialStatus = credentialStatus,
maybeRefreshService = None,
maybeEvidence = None,
maybeTermsOfUse = None,
Expand Down Expand Up @@ -190,6 +195,51 @@ object JWTVerificationTest extends ZIOSpecDefault {
)
)
},
test("fail verification if proof is valid but credential is revoked at the give status list index given list") {
val revokedStatus: List[CredentialStatus] = List(
org.hyperledger.identus.pollux.vc.jwt.CredentialStatus(
id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#1",
statusPurpose = StatusPurpose.Revocation,
`type` = "StatusList2021Entry",
statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9",
statusListIndex = 1
),
org.hyperledger.identus.pollux.vc.jwt.CredentialStatus(
id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#2",
statusPurpose = StatusPurpose.Suspension,
`type` = "StatusList2021Entry",
statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9",
statusListIndex = 1
)
)

val urlResolver = new UriResolver {
override def resolve(uri: String): IO[GenericUriResolverError, String] = {
ZIO.succeed(statusListCredentialString)
}
}

val genericUriResolver = GenericUriResolver(
Map(
"data" -> DataUrlResolver(),
"http" -> urlResolver,
"https" -> urlResolver
)
)
val issuer = createUser("did:prism:issuer")
val jwtCredential = createJwtCredential(issuer, credentialStatus = Some(revokedStatus))

for {
validation <- JwtCredential.verifyRevocationStatusJwt(jwtCredential)(genericUriResolver)
} yield assertTrue(
validation.fold(
chunk =>
chunk.length == 2 && chunk.head.contentEquals("Credential is revoked") && chunk.tail.head
.contentEquals("Credential is revoked"),
_ => false
)
)
},
test("validate dates happy path") {
val issuer = createUser("did:prism:issuer")
val jwtCredential = createJwtCredential(issuer)
Expand Down Expand Up @@ -223,6 +273,29 @@ object JWTVerificationTest extends ZIOSpecDefault {
jwtWithObjectIssuerIssuer.equals(jwtIssuer)
)
},
test("validate credential status list") {
val issuer = createUser("did:prism:issuer")
val status = CredentialStatus(id = "id", `type` = "type", statusPurpose = Revocation, 1, "1")
val encodedJwtWithStatusList = createJwtCredential(
issuer,
false,
Some(List(status))
)
val econdedJwtWithStatusObject = createJwtCredential(issuer, true, Some(status))
for {
decodeJwtWithStatusList <- JwtCredential
.decodeJwt(encodedJwtWithStatusList)
decodeJwtWithStatusObject <- JwtCredential
.decodeJwt(econdedJwtWithStatusObject)
statusFromList = decodeJwtWithStatusList.vc.maybeCredentialStatus.map {
case list: List[CredentialStatus] => list.head
case _: CredentialStatus => throw new IllegalStateException("List expected")
}.get
statusFromObjet = decodeJwtWithStatusObject.vc.maybeCredentialStatus.get
} yield assertTrue(
statusFromList.equals(statusFromObjet)
)
},
test("validate dates should fail given after valid until") {
val issuer = createUser("did:prism:issuer")
val jwtCredential = createJwtCredential(issuer)
Expand Down
Loading