From ad946cf3f635b882d772a00b0202b957a1cb82cb Mon Sep 17 00:00:00 2001 From: Bassam Date: Mon, 30 Sep 2024 11:40:16 -0400 Subject: [PATCH] feat: VC support for Array of credential Status (#1383) Signed-off-by: Bassam Riman --- .../vc/jwt/VerifiableCredentialPayload.scala | 56 ++++++++++---- .../pollux/vc/jwt/JWTVerificationTest.scala | 77 ++++++++++++++++++- 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala index 3d24747bd1..5638ba028a 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala @@ -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] @@ -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] @@ -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], @@ -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) @@ -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 { @@ -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]] @@ -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]] @@ -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))) @@ -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 } } @@ -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)( diff --git a/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala b/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala index e09f9e6e96..8222a4fe64 100644 --- a/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala +++ b/pollux/vc-jwt/src/test/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerificationTest.scala @@ -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 @@ -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 @@ -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, @@ -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) @@ -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)