From f80b3c34588437b131ce872fd86f93e75dcd035f Mon Sep 17 00:00:00 2001 From: patlo-iog Date: Thu, 12 Sep 2024 12:36:12 +0700 Subject: [PATCH] feat: presentation_submission validation logic (#1332) Signed-off-by: Pat Losoponkul --- build.sbt | 2 +- .../pollux/prex/PresentationDefinition.scala | 23 +- .../PresentationDefinitionValidator.scala | 17 +- .../pollux/prex/PresentationSubmission.scala | 31 + .../PresentationSubmissionVerification.scala | 229 ++++++++ .../test/resources/ps/basic_presentation.json | 23 + .../resources/ps/nested_presentation.json | 23 + .../PresentationDefinitionValidatorSpec.scala | 30 +- .../prex/PresentationSubmissionSpec.scala | 33 ++ ...esentationSubmissionVerificationSpec.scala | 549 ++++++++++++++++++ .../identus/shared/json/JsonPath.scala | 39 +- .../identus/shared/json/JsonPathSpec.scala | 48 +- 12 files changed, 1006 insertions(+), 41 deletions(-) create mode 100644 pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmission.scala create mode 100644 pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerification.scala create mode 100644 pollux/prex/src/test/resources/ps/basic_presentation.json create mode 100644 pollux/prex/src/test/resources/ps/nested_presentation.json create mode 100644 pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionSpec.scala create mode 100644 pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerificationSpec.scala diff --git a/build.sbt b/build.sbt index c5361912fb..9694cf0e98 100644 --- a/build.sbt +++ b/build.sbt @@ -811,7 +811,7 @@ lazy val polluxPreX = project .in(file("pollux/prex")) .settings(commonSetttings) .settings(name := "pollux-prex") - .dependsOn(shared, sharedJson) + .dependsOn(shared, sharedJson, polluxVcJWT) // ######################## // ### Pollux Anoncreds ### diff --git a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinition.scala b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinition.scala index 7a5b8ac47f..21ded1e947 100644 --- a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinition.scala +++ b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinition.scala @@ -16,7 +16,7 @@ object JsonPathValue { given Conversion[String, JsonPathValue] = identity extension (jpv: JsonPathValue) { - def toJsonPath: IO[JsonPathError, JsonPath] = JsonPath.compile(jpv) + def toJsonPath: Either[JsonPathError, JsonPath] = JsonPath.compile(jpv) def value: String = jpv } } @@ -65,7 +65,26 @@ object Ldp { given Decoder[Ldp] = deriveDecoder[Ldp] } -case class ClaimFormat(jwt: Option[Jwt] = None, ldp: Option[Ldp] = None) +enum ClaimFormatValue(val value: String) { + case jwt_vc extends ClaimFormatValue("jwt_vc") + case jwt_vp extends ClaimFormatValue("jwt_vp") +} + +object ClaimFormatValue { + given Encoder[ClaimFormatValue] = Encoder.encodeString.contramap(_.value) + given Decoder[ClaimFormatValue] = Decoder.decodeString.emap { + case "jwt_vc" => Right(ClaimFormatValue.jwt_vc) + case "jwt_vp" => Right(ClaimFormatValue.jwt_vp) + case other => Left(s"Invalid ClaimFormatValue: $other") + } +} + +case class ClaimFormat( + jwt: Option[Jwt] = None, + jwt_vc: Option[Jwt] = None, + jwt_vp: Option[Jwt] = None, + ldp: Option[Ldp] = None +) object ClaimFormat { given Encoder[ClaimFormat] = deriveEncoder[ClaimFormat] diff --git a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidator.scala b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidator.scala index 9b47a914ae..9b21a17d94 100644 --- a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidator.scala +++ b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidator.scala @@ -1,6 +1,7 @@ package org.hyperledger.identus.pollux.prex import org.hyperledger.identus.pollux.prex.PresentationDefinitionError.{ + DuplicatedDescriptorId, InvalidFilterJsonPath, InvalidFilterJsonSchema, JsonSchemaOptionNotSupported @@ -21,6 +22,12 @@ sealed trait PresentationDefinitionError extends Failure { } object PresentationDefinitionError { + final case class DuplicatedDescriptorId(ids: Seq[String]) extends PresentationDefinitionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"PresentationDefinition input_descriptors contains duplicated id(s): ${ids.mkString(", ")}" + } + final case class InvalidFilterJsonPath(path: String, error: JsonPathError) extends PresentationDefinitionError { override def statusCode: StatusCode = StatusCode.BadRequest override def userFacingMessage: String = @@ -67,16 +74,24 @@ class PresentationDefinitionValidatorImpl(filterSchemaValidator: JsonSchemaValid val filters = fields.flatMap(_.filter) for { + _ <- validateUniqueDescriptorIds(pd.input_descriptors) _ <- validateJsonPaths(paths) _ <- validateFilters(filters) _ <- validateAllowedFilterSchemaKeys(filters) } yield () } + private def validateUniqueDescriptorIds(descriptors: Seq[InputDescriptor]): IO[PresentationDefinitionError, Unit] = { + val ids = descriptors.map(_.id) + if ids.distinct.size == ids.size + then ZIO.unit + else ZIO.fail(DuplicatedDescriptorId(ids)) + } + private def validateJsonPaths(paths: Seq[JsonPathValue]): IO[PresentationDefinitionError, Unit] = { ZIO .foreach(paths) { path => - path.toJsonPath.mapError(InvalidFilterJsonPath(path.value, _)) + ZIO.fromEither(path.toJsonPath).mapError(InvalidFilterJsonPath(path.value, _)) } .unit } diff --git a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmission.scala b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmission.scala new file mode 100644 index 0000000000..10475badc0 --- /dev/null +++ b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmission.scala @@ -0,0 +1,31 @@ +package org.hyperledger.identus.pollux.prex + +import io.circe.* +import io.circe.generic.semiauto.* + +case class InputDescriptorMapping( + id: String, + format: ClaimFormatValue, + path: JsonPathValue, + path_nested: Option[InputDescriptorMapping] +) + +object InputDescriptorMapping { + given Encoder[InputDescriptorMapping] = deriveEncoder[InputDescriptorMapping] + given Decoder[InputDescriptorMapping] = deriveDecoder[InputDescriptorMapping] +} + +/** Refer to Presentation + * Definition + */ +case class PresentationSubmission( + definition_id: String, + id: String = java.util.UUID.randomUUID.toString(), // UUID + descriptor_map: Seq[InputDescriptorMapping] = Seq.empty +) + +object PresentationSubmission { + given Encoder[PresentationSubmission] = deriveEncoder[PresentationSubmission] + given Decoder[PresentationSubmission] = deriveDecoder[PresentationSubmission] +} diff --git a/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerification.scala b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerification.scala new file mode 100644 index 0000000000..fe167a1122 --- /dev/null +++ b/pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerification.scala @@ -0,0 +1,229 @@ +package org.hyperledger.identus.pollux.prex + +import io.circe.* +import io.circe.syntax.* +import org.hyperledger.identus.pollux.prex.PresentationSubmissionError.{ + ClaimDecodeFailure, + ClaimFormatVerificationFailure, + ClaimNotSatisfyInputConstraint, + InvalidDataTypeForClaimFormat, + InvalidJsonPath, + InvalidNestedPathDescriptorId, + InvalidSubmissionId, + JsonPathNotFound, + SubmissionNotSatisfyInputDescriptors +} +import org.hyperledger.identus.pollux.vc.jwt.{JWT, JwtCredential, JwtPresentation} +import org.hyperledger.identus.pollux.vc.jwt.CredentialPayload.Implicits.* +import org.hyperledger.identus.pollux.vc.jwt.PresentationPayload.Implicits.* +import org.hyperledger.identus.shared.json.{JsonInterop, JsonPath, JsonPathError, JsonSchemaValidatorImpl} +import org.hyperledger.identus.shared.models.{Failure, StatusCode} +import zio.* +import zio.json.ast.Json as ZioJson + +sealed trait PresentationSubmissionError extends Failure { + override def namespace: String = "PresentationSubmissionError" +} + +object PresentationSubmissionError { + case class InvalidSubmissionId(expected: String, actual: String) extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"Expected presentation_submission id to be $expected, got $actual" + } + + case class InvalidNestedPathDescriptorId(expected: String, actual: String) extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"Descriptor id for all nested_path level must be the same. Expected id $expected, got $actual" + } + + case class SubmissionNotSatisfyInputDescriptors(required: Seq[String], provided: Seq[String]) + extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"Submission does not satisfy all input descriptors. Required: ${required + .mkString("[", ", ", "]")}, Provided: ${provided.mkString("[", ", ", "]")}" + } + + case class InvalidDataTypeForClaimFormat(format: ClaimFormatValue, path: JsonPathValue, expectedType: String) + extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"Expect json to be type $expectedType for claim format ${format.value} on path ${path.value}" + } + + case class InvalidJsonPath(path: JsonPathValue, error: JsonPathError) extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"Invalid json path ${path.value} in the presentation_submission" + } + + case class JsonPathNotFound(path: JsonPathValue) extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"Json data at path ${path.value} not found in the presentation_submission" + } + + case class ClaimDecodeFailure(format: ClaimFormatValue, path: JsonPathValue, error: String) + extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"Unable to decode claim according to format ${format.value} at path ${path.value}: $error" + } + + case class ClaimFormatVerificationFailure(format: ClaimFormatValue, path: JsonPathValue, error: String) + extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"Claim format ${format.value} at path ${path.value} failed verification with errors: $error" + } + + case class ClaimNotSatisfyInputConstraint(id: String) extends PresentationSubmissionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"Claim in presentation_submission with id $id does not satisfy input constraints" + } +} + +case class ClaimFormatVerification( + jwtVp: JWT => IO[String, Unit], + jwtVc: JWT => IO[String, Unit], +) + +// Known issues +// 1. does not respect jwt format alg in presentation_definition +object PresentationSubmissionVerification { + + def verify( + pd: PresentationDefinition, + ps: PresentationSubmission, + rootTraversalObject: ZioJson, + )(formatVerification: ClaimFormatVerification): IO[PresentationSubmissionError, Unit] = { + for { + _ <- verifySubmissionId(pd, ps) + _ <- verifySubmissionRequirement(pd, ps) + entries <- ZIO + .foreach(ps.descriptor_map) { descriptor => + extractSubmissionEntry(rootTraversalObject, descriptor)(formatVerification).map(descriptor.id -> _) + } + _ <- verifyInputConstraints(pd, entries) + } yield () + } + + private def verifySubmissionId( + pd: PresentationDefinition, + ps: PresentationSubmission + ): IO[PresentationSubmissionError, Unit] = { + if pd.id == ps.definition_id + then ZIO.unit + else ZIO.fail(InvalidSubmissionId(pd.id, ps.id)) + } + + // This is not yet fully supported as described in https://identity.foundation/presentation-exchange/spec/v2.1.1/#submission-requirement-feature + // It is now a simple check that submission descriptor_map satisfies all input_descriptors + private def verifySubmissionRequirement( + pd: PresentationDefinition, + ps: PresentationSubmission + ): IO[PresentationSubmissionError, Unit] = { + val pdIds = pd.input_descriptors.map(_.id) + val psIds = ps.descriptor_map.map(_.id) + if pdIds.toSet == psIds.toSet + then ZIO.unit + else ZIO.fail(SubmissionNotSatisfyInputDescriptors(pdIds.toSeq, psIds.toSeq)) + } + + private def verifyInputConstraints( + pd: PresentationDefinition, + entries: Seq[(String, ZioJson)] + ): IO[PresentationSubmissionError, Unit] = { + val descriptorLookup = pd.input_descriptors.map(d => d.id -> d).toMap + val descriptorWithEntry = entries.flatMap { case (id, entry) => descriptorLookup.get(id).map(_ -> entry) } + ZIO + .foreach(descriptorWithEntry) { case (descriptor, entry) => + verifyInputConstraint(descriptor, entry) + } + .unit + } + + private def verifyInputConstraint( + descriptor: InputDescriptor, + entry: ZioJson + ): IO[PresentationSubmissionError, Unit] = { + val mandatoryFields = descriptor.constraints.fields + .getOrElse(Nil) + .filterNot(_.optional.getOrElse(false)) // optional field doesn't have to pass contraints + + // all fields need to be valid + ZIO + .foreach(mandatoryFields) { field => + // only one of the paths need to be valid + ZIO + .validateFirst(field.path) { p => + for { + jsonPath <- ZIO.fromEither(p.toJsonPath) + jsonAtPath <- ZIO.fromEither(jsonPath.read(entry)) + maybeFilter <- ZIO.foreach(field.filter)(_.toJsonSchema) + _ <- ZIO.foreach(maybeFilter)(JsonSchemaValidatorImpl(_).validate(jsonAtPath.toString())) + } yield () + } + .mapError(_ => ClaimNotSatisfyInputConstraint(descriptor.id)) + } + .unit + } + + private def extractSubmissionEntry( + traversalObject: ZioJson, + descriptor: InputDescriptorMapping + )(formatVerification: ClaimFormatVerification): IO[PresentationSubmissionError, ZioJson] = { + for { + path <- ZIO + .fromEither(descriptor.path.toJsonPath) + .mapError(InvalidJsonPath(descriptor.path, _)) + jsonAtPath <- ZIO + .fromEither(path.read(traversalObject)) + .mapError(_ => JsonPathNotFound(descriptor.path)) + currentNode <- descriptor.format match { + case ClaimFormatValue.jwt_vc => verifyJwtVc(jsonAtPath, descriptor.path)(formatVerification.jwtVc) + case ClaimFormatValue.jwt_vp => verifyJwtVp(jsonAtPath, descriptor.path)(formatVerification.jwtVp) + } + leafNode <- descriptor.path_nested.fold(ZIO.succeed(currentNode)) { nestedDescriptor => + if descriptor.id != nestedDescriptor.id + then ZIO.fail(InvalidNestedPathDescriptorId(descriptor.id, nestedDescriptor.id)) + else extractSubmissionEntry(currentNode, nestedDescriptor)(formatVerification) + } + } yield leafNode + } + + private def verifyJwtVc( + json: ZioJson, + path: JsonPathValue + )(formatVerification: JWT => IO[String, Unit]): IO[PresentationSubmissionError, ZioJson] = { + val format = ClaimFormatValue.jwt_vc + for { + jwt <- ZIO + .fromOption(json.asString) + .map(JWT(_)) + .mapError(_ => InvalidDataTypeForClaimFormat(format, path, "string")) + payload <- JwtCredential + .decodeJwt(jwt) + .mapError(e => ClaimDecodeFailure(format, path, e)) + _ <- formatVerification(jwt) + .mapError(errors => ClaimFormatVerificationFailure(format, path, errors.mkString)) + } yield JsonInterop.toZioJsonAst(payload.asJson) + } + + private def verifyJwtVp( + json: ZioJson, + path: JsonPathValue + )(formatVerification: JWT => IO[String, Unit]): IO[PresentationSubmissionError, ZioJson] = { + val format = ClaimFormatValue.jwt_vp + for { + jwt <- ZIO + .fromOption(json.asString) + .map(JWT(_)) + .mapError(_ => InvalidDataTypeForClaimFormat(format, path, "string")) + payload <- ZIO + .fromTry(JwtPresentation.decodeJwt(jwt)) + .mapError(e => ClaimDecodeFailure(format, path, e.getMessage())) + _ <- formatVerification(jwt) + .mapError(errors => ClaimFormatVerificationFailure(format, path, errors.mkString)) + } yield JsonInterop.toZioJsonAst(payload.asJson) + } +} diff --git a/pollux/prex/src/test/resources/ps/basic_presentation.json b/pollux/prex/src/test/resources/ps/basic_presentation.json new file mode 100644 index 0000000000..942667c045 --- /dev/null +++ b/pollux/prex/src/test/resources/ps/basic_presentation.json @@ -0,0 +1,23 @@ +{ + "presentation_submission": { + "id": "a30e3b91-fb77-4d22-95fa-871689c322e2", + "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "descriptor_map": [ + { + "id": "banking_input_2", + "format": "jwt_vc", + "path": "$.verifiableCredential[0]" + }, + { + "id": "employment_input", + "format": "jwt_vc", + "path": "$.verifiableCredential[1]" + }, + { + "id": "citizenship_input_1", + "format": "jwt_vc", + "path": "$.verifiableCredential[2]" + } + ] + } +} diff --git a/pollux/prex/src/test/resources/ps/nested_presentation.json b/pollux/prex/src/test/resources/ps/nested_presentation.json new file mode 100644 index 0000000000..a5ecefb2f7 --- /dev/null +++ b/pollux/prex/src/test/resources/ps/nested_presentation.json @@ -0,0 +1,23 @@ +{ + "presentation_submission": { + "id": "a30e3b91-fb77-4d22-95fa-871689c322e2", + "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "descriptor_map": [ + { + "id": "banking_input_2", + "format": "jwt_vp", + "path": "$.outerClaim[0]", + "path_nested": { + "id": "banking_input_2", + "format": "jwt_vc", + "path": "$.innerClaim[1]", + "path_nested": { + "id": "banking_input_2", + "format": "jwt_vc", + "path": "$.mostInnerClaim[2]" + } + } + } + ] + } +} diff --git a/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidatorSpec.scala b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidatorSpec.scala index 108168ae62..290c72198d 100644 --- a/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidatorSpec.scala +++ b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationDefinitionValidatorSpec.scala @@ -4,6 +4,7 @@ import io.circe.* import io.circe.generic.auto.* import io.circe.parser.* import org.hyperledger.identus.pollux.prex.PresentationDefinitionError.{ + DuplicatedDescriptorId, InvalidFilterJsonPath, InvalidFilterJsonSchema, JsonSchemaOptionNotSupported @@ -134,7 +135,34 @@ object PresentationDefinitionValidatorSpec extends ZIOSpecDefault { .map(_.presentation_definition) exit <- validator.validate(pd).exit } yield assert(exit)(failsWithA[InvalidFilterJsonPath]) - } + }, + test("reject when descriptor id is not unique") { + val pdJson = + """{ + | "presentation_definition": { + | "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + | "input_descriptors": [ + | { + | "id": "wa_driver_license", + | "constraints": {} + | }, + | { + | "id": "wa_driver_license", + | "constraints": {} + | } + | ] + | } + |} + """.stripMargin + + for { + validator <- ZIO.service[PresentationDefinitionValidator] + pd <- ZIO + .fromEither(decode[ExampleTransportEnvelope](pdJson)) + .map(_.presentation_definition) + exit <- validator.validate(pd).exit + } yield assert(exit)(failsWithA[DuplicatedDescriptorId]) + }, ) .provide(PresentationDefinitionValidatorImpl.layer) diff --git a/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionSpec.scala b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionSpec.scala new file mode 100644 index 0000000000..fdc723a246 --- /dev/null +++ b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionSpec.scala @@ -0,0 +1,33 @@ +package org.hyperledger.identus.pollux.prex + +import io.circe.* +import io.circe.generic.auto.* +import io.circe.parser.* +import zio.* +import zio.test.* + +import scala.io.Source +import scala.util.Using + +object PresentationSubmissionSpec extends ZIOSpecDefault { + + final case class ExampleTransportEnvelope(presentation_submission: PresentationSubmission) + + override def spec = suite("PresentationSubmissionSpec")( + test("parse presentation-submission exmaples from spec") { + val resourcePaths = Seq( + "ps/basic_presentation.json", + "ps/nested_presentation.json", + ) + ZIO + .foreach(resourcePaths) { path => + ZIO + .fromTry(Using(Source.fromResource(path))(_.mkString)) + .flatMap(json => ZIO.fromEither(decode[ExampleTransportEnvelope](json))) + .map(_.presentation_submission) + } + .as(assertCompletes) + } + ) + +} diff --git a/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerificationSpec.scala b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerificationSpec.scala new file mode 100644 index 0000000000..60b025826e --- /dev/null +++ b/pollux/prex/src/test/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerificationSpec.scala @@ -0,0 +1,549 @@ +package org.hyperledger.identus.pollux.prex + +import io.circe.* +import io.circe.parser.* +import org.hyperledger.identus.castor.core.model.did.DID +import org.hyperledger.identus.pollux.prex.PresentationSubmissionError.{ + ClaimFormatVerificationFailure, + ClaimNotSatisfyInputConstraint, + InvalidNestedPathDescriptorId, + InvalidSubmissionId, + SubmissionNotSatisfyInputDescriptors +} +import org.hyperledger.identus.pollux.vc.jwt.{ + ES256KSigner, + Issuer, + JWT, + JwtCredential, + JwtCredentialPayload, + JwtPresentation, + JwtPresentationPayload, + JwtVc, + JwtVerifiableCredentialPayload, + JwtVp, + VerifiableCredentialPayload +} +import org.hyperledger.identus.shared.crypto.Apollo +import zio.* +import zio.json.ast.Json as ZioJson +import zio.test.* +import zio.test.Assertion.* + +import java.time.Instant + +object PresentationSubmissionVerificationSpec extends ZIOSpecDefault { + + private def decodeUnsafe[T: Decoder](json: String): T = decode[T](json).toOption.get + private def parseUnsafe(json: String): Json = parse(json).toOption.get + + private val noopFormatVerification = ClaimFormatVerification(jwtVp = _ => ZIO.unit, jwtVc = _ => ZIO.unit) + private val basePd: PresentationDefinition = + decodeUnsafe[PresentationDefinition]( + """ + |{ + | "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + | "input_descriptors": [] + |} + """.stripMargin + ) + private val basePs: PresentationSubmission = + decodeUnsafe[PresentationSubmission]( + """ + |{ + | "id": "a30e3b91-fb77-4d22-95fa-871689c322e2", + | "definition_id": "32f54163-7166-48f1-93d8-ff217bdb0653", + | "descriptor_map": [] + |} + """.stripMargin + ) + + private def generateVcPayload(subject: Json): JwtCredentialPayload = { + val iss = "did:example:ebfeb1f712ebc6f1c276e12ec21" + val jwtCredentialNbf = Instant.parse("2010-01-01T00:00:00Z") + JwtCredentialPayload( + iss = iss, + maybeSub = None, + vc = JwtVc( + `@context` = Set("https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1"), + `type` = Set("VerifiableCredential", "UniversityDegreeCredential"), + maybeCredentialSchema = None, + credentialSubject = subject, + maybeCredentialStatus = None, + maybeRefreshService = None, + maybeEvidence = None, + maybeTermsOfUse = None, + maybeValidFrom = None, + maybeValidUntil = None, + maybeIssuer = Some(Left(iss)) + ), + nbf = jwtCredentialNbf, + aud = Set.empty, + maybeExp = None, + maybeJti = None + ) + } + + private def generateVpPayload(vcs: Seq[VerifiableCredentialPayload]): JwtPresentationPayload = { + val iss = "did:example:ebfeb1f712ebc6f1c276e12ec21" + JwtPresentationPayload( + iss = iss, + vp = JwtVp( + `@context` = + Vector("https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1"), + `type` = Vector("VerifiablePresentation"), + verifiableCredential = vcs.toVector + ), + maybeNbf = None, + aud = Vector.empty, + maybeExp = None, + maybeJti = None, + maybeNonce = None, + ) + } + + private def generateJwtVc(payload: JwtCredentialPayload): JWT = { + val keyPair = Apollo.default.secp256k1.generateKeyPair + val publicKey = keyPair.publicKey + val privateKey = keyPair.privateKey + val issuer = Issuer( + DID.fromString(payload.iss).toOption.get, + ES256KSigner(privateKey.toJavaPrivateKey, None), + publicKey.toJavaPublicKey + ) + JwtCredential.encodeJwt(payload, issuer) + } + + private def generateJwtVp(payload: JwtPresentationPayload): JWT = { + val keyPair = Apollo.default.secp256k1.generateKeyPair + val publicKey = keyPair.publicKey + val privateKey = keyPair.privateKey + val issuer = Issuer( + DID.fromString(payload.iss).toOption.get, + ES256KSigner(privateKey.toJavaPrivateKey, None), + publicKey.toJavaPublicKey + ) + JwtPresentation.encodeJwt(payload, issuer) + } + + private def assertSubmissionVerification( + descriptorsJson: String, + descriptorMapJson: String, + jwt: JWT, + formatVerification: ClaimFormatVerification = noopFormatVerification + )( + assertion: Assertion[Exit[PresentationSubmissionError, Unit]] + ) = { + val descriptors = decodeUnsafe[Seq[InputDescriptor]](descriptorsJson) + val descriptorMap = decodeUnsafe[Seq[InputDescriptorMapping]](descriptorMapJson) + val pd = basePd.copy(input_descriptors = descriptors) + val ps = basePs.copy(descriptor_map = descriptorMap) + for { + result <- PresentationSubmissionVerification + .verify(pd, ps, ZioJson.Str(jwt.value))(formatVerification) + .exit + } yield assert(result)(assertion) + } + + override def spec: Spec[TestEnvironment & Scope, Any] = suite("PresentationSubmissionVerificationSpec")( + test("descriptor and submission id not match") { + val ps = basePs.copy(definition_id = "random-id") + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + for { + result <- PresentationSubmissionVerification + .verify(basePd, ps, ZioJson.Str(jwtVc.value))(noopFormatVerification) + .exit + } yield assert(result)(failsWithA[InvalidSubmissionId]) + }, + test("empty descriptor and submission") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification("[]", "[]", jwtVc)(succeeds(anything)) + }, + test("one descriptor and corresponding submission") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | {"id": "university_degree", "constraints": {}} + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor with no submission") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | {"id": "university_degree", "constraints": {}} + |] + """.stripMargin, + "[]", + jwtVc + )(failsWithA[SubmissionNotSatisfyInputDescriptors]) + }, + test("submission with no descriptor") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + "[]", + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(failsWithA[SubmissionNotSatisfyInputDescriptors]) + }, + test("descriptor with path verification") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | {"path": ["$.vc.credentialSubject.name"]} + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor with multiple paths verification") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | {"path": ["$.vc.credentialSubject.fullName", "$.vc.credentialSubject.name"]} + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor with path and filter verification") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "filter": { + | "type": "string", + | "const": "alice" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor with multiple paths and filter verification") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.fullName", "$.vc.credentialSubject.name"], + | "filter": { + | "type": "string", + | "const": "alice" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc, + )(succeeds(anything)) + }, + test("descriptor and submission that dosn't satisfy the filter") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "filter": { + | "type": "string", + | "const": "bob" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(failsWithA[ClaimNotSatisfyInputConstraint]) + }, + test("descriptor with multiple fields verification") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice", "degree": "Finance"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "filter": { + | "type": "string", + | "const": "alice" + | } + | }, + | { + | "path": ["$.vc.credentialSubject.degree"], + | "filter": { + | "type": "string", + | "const": "Finance" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("submission with nested path jwt_vc inside jwt_vp") { + val vcPayload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(vcPayload) + val vpPayload = generateVpPayload(Seq(JwtVerifiableCredentialPayload(jwtVc))) + val jwtVp = generateJwtVp(vpPayload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "filter": { + | "type": "string", + | "const": "alice" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | { + | "id": "university_degree", + | "format": "jwt_vp", + | "path": "$", + | "path_nested": { + | "id": "university_degree", + | "format": "jwt_vc", + | "path": "$.vp.verifiableCredential[0]" + | } + | } + |] + """.stripMargin, + jwtVp + )(succeeds(anything)) + }, + test("submission with nested_path having different id at each level should fail") { + val vcPayload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(vcPayload) + val vpPayload = generateVpPayload(Seq(JwtVerifiableCredentialPayload(jwtVc))) + val jwtVp = generateJwtVp(vpPayload) + assertSubmissionVerification( + """[ + | {"id": "university_degree", "constraints": {}} + |] + """.stripMargin, + """[ + | { + | "id": "university_degree", + | "format": "jwt_vp", + | "path": "$", + | "path_nested": { + | "id": "university_degree_2", + | "format": "jwt_vc", + | "path": "$.vp.verifiableCredential[0]" + | } + | } + |] + """.stripMargin, + jwtVp + )(failsWithA[InvalidNestedPathDescriptorId]) + }, + test("multiple descriptors with corresponding submission") { + val vcPayload1 = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc1 = generateJwtVc(vcPayload1) + val vcPayload2 = generateVcPayload(parseUnsafe("""{"vehicle_type": "car"}""")) + val jwtVc2 = generateJwtVc(vcPayload2) + val vpPayload = generateVpPayload( + Seq( + JwtVerifiableCredentialPayload(jwtVc1), + JwtVerifiableCredentialPayload(jwtVc2) + ) + ) + val jwtVp = generateJwtVp(vpPayload) + assertSubmissionVerification( + """[ + | {"id": "university_degree", "constraints": {}}, + | {"id": "driving_license", "constraints": {}} + |] + """.stripMargin, + """[ + | { + | "id": "university_degree", + | "format": "jwt_vp", + | "path": "$", + | "path_nested": { + | "id": "university_degree", + | "format": "jwt_vc", + | "path": "$.vp.verifiableCredential[0]" + | } + | }, + | { + | "id": "driving_license", + | "format": "jwt_vp", + | "path": "$", + | "path_nested": { + | "id": "driving_license", + | "format": "jwt_vc", + | "path": "$.vp.verifiableCredential[1]" + | } + | } + |] + """.stripMargin, + jwtVp + )(succeeds(anything)) + }, + test("descriptor with optional field and submission that omit optional fields") { + val payload = generateVcPayload(parseUnsafe("""{"gpa": 4.00}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "optional": true + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor with optional field and submission with optional fields that don't satisfy constraints") { + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | { + | "id": "university_degree", + | "constraints": { + | "fields": [ + | { + | "path": ["$.vc.credentialSubject.name"], + | "optional": true, + | "filter": { + | "type": "string", + | "const": "bob" + | } + | } + | ] + | } + | } + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc + )(succeeds(anything)) + }, + test("descriptor and submission that fail the claim format decoding") { + val formatVerification = noopFormatVerification.copy( + jwtVc = _ => ZIO.fail("jwt is missing some required properties") + ) + val payload = generateVcPayload(parseUnsafe("""{"name": "alice"}""")) + val jwtVc = generateJwtVc(payload) + assertSubmissionVerification( + """[ + | {"id": "university_degree", "constraints": {}} + |] + """.stripMargin, + """[ + | {"id": "university_degree", "format": "jwt_vc", "path": "$"} + |] + """.stripMargin, + jwtVc, + formatVerification + )(failsWithA[ClaimFormatVerificationFailure]) + } + ) + +} diff --git a/shared/json/src/main/scala/org/hyperledger/identus/shared/json/JsonPath.scala b/shared/json/src/main/scala/org/hyperledger/identus/shared/json/JsonPath.scala index 48770dd784..d1624331a1 100644 --- a/shared/json/src/main/scala/org/hyperledger/identus/shared/json/JsonPath.scala +++ b/shared/json/src/main/scala/org/hyperledger/identus/shared/json/JsonPath.scala @@ -7,6 +7,8 @@ import zio.* import zio.json.* import zio.json.ast.Json +import scala.util.Try + sealed trait JsonPathError extends Failure { override def namespace: String = "JsonPathError" } @@ -21,32 +23,45 @@ object JsonPathError { override def statusCode: StatusCode = StatusCode.BadRequest override def userFacingMessage: String = s"The json path '$path' cannot be found in a json" } + + final case class UnexpectedCompilePathError(path: String, e: Throwable) extends JsonPathError { + override def statusCode: StatusCode = StatusCode.InternalServerError + override def userFacingMessage: String = s"An unhandled error occurred while compiling the JsonPath for $path" + } + + final case class UnexpectedReadPathError(path: String, e: Throwable) extends JsonPathError { + override def statusCode: StatusCode = StatusCode.InternalServerError + override def userFacingMessage: String = s"An unhandled error occurred while reading the JsonPath for $path" + } } opaque type JsonPath = JaywayJsonPath object JsonPath { - def compile(path: String): IO[JsonPathError, JsonPath] = { - ZIO - .attempt(JaywayJsonPath.compile(path)) - .refineOrDie { + def compileUnsafe(path: String): JsonPath = JaywayJsonPath.compile(path) + + def compile(path: String): Either[JsonPathError, JsonPath] = + Try(compileUnsafe(path)).toEither.left + .map { case e: IllegalArgumentException => JsonPathError.InvalidPathInput(e.getMessage()) case e: InvalidPathException => JsonPathError.InvalidPathInput(e.getMessage()) + case e => JsonPathError.UnexpectedCompilePathError(path, e) } - } extension (jsonPath: JsonPath) { - def read(json: Json): IO[JsonPathError, Json] = { + def read(json: Json): Either[JsonPathError, Json] = { val jsonProvider = JacksonJsonProvider() val document = JaywayJsonPath.parse(json.toString()) for { - queriedObj <- ZIO - .attempt(document.read[java.lang.Object](jsonPath)) - .refineOrDie { case e: PathNotFoundException => - JsonPathError.PathNotFound(jsonPath.getPath()) - } + queriedObj <- Try(document.read[java.lang.Object](jsonPath)).toEither.left.map { + case e: PathNotFoundException => JsonPathError.PathNotFound(jsonPath.getPath()) + case e => JsonPathError.UnexpectedReadPathError(jsonPath.getPath(), e) + } queriedJsonStr = jsonProvider.toJson(queriedObj) - queriedJson <- ZIO.fromEither(queriedJsonStr.fromJson[Json]).orDieWith(Exception(_)) + queriedJson <- queriedJsonStr + .fromJson[Json] + .left + .map(e => JsonPathError.UnexpectedReadPathError(jsonPath.getPath(), Exception(e))) } yield queriedJson } } diff --git a/shared/json/src/test/scala/org/hyperledger/identus/shared/json/JsonPathSpec.scala b/shared/json/src/test/scala/org/hyperledger/identus/shared/json/JsonPathSpec.scala index 5f4d41c612..4158ee323a 100644 --- a/shared/json/src/test/scala/org/hyperledger/identus/shared/json/JsonPathSpec.scala +++ b/shared/json/src/test/scala/org/hyperledger/identus/shared/json/JsonPathSpec.scala @@ -28,7 +28,7 @@ object JsonPathSpec extends ZIOSpecDefault { "$['foo']['bar']" ) ZIO - .foreach(paths)(JsonPath.compile) + .foreach(paths)(p => ZIO.fromEither(JsonPath.compile(p))) .as(assertCompletes) }, test("do not accept invalid json path") { @@ -40,7 +40,7 @@ object JsonPathSpec extends ZIOSpecDefault { "hello world", ) ZIO - .foreach(paths)(p => JsonPath.compile(p).flip) + .foreach(paths)(p => ZIO.fromEither(JsonPath.compile(p)).flip) .map { errors => assert(errors)(forall(isSubtype[InvalidPathInput](anything))) } @@ -60,20 +60,20 @@ object JsonPathSpec extends ZIOSpecDefault { """.stripMargin for { json <- ZIO.fromEither(jsonStr.fromJson[Json]) - namePath <- JsonPath.compile("$.vc.name") - agePath <- JsonPath.compile("$.vc.age") - degreePath <- JsonPath.compile("$.vc.degree") - petPath <- JsonPath.compile("$.vc.pets") - firstPetPath <- JsonPath.compile("$.vc.pets[0]") - isEmployedPath <- JsonPath.compile("$.vc.isEmployed") - languagesPath <- JsonPath.compile("$.vc.languages") - name <- namePath.read(json) - age <- agePath.read(json) - degree <- degreePath.read(json) - pet <- petPath.read(json) - firstPet <- firstPetPath.read(json) - isEmployed <- isEmployedPath.read(json) - languages <- languagesPath.read(json) + namePath <- ZIO.fromEither(JsonPath.compile("$.vc.name")) + agePath <- ZIO.fromEither(JsonPath.compile("$.vc.age")) + degreePath <- ZIO.fromEither(JsonPath.compile("$.vc.degree")) + petPath <- ZIO.fromEither(JsonPath.compile("$.vc.pets")) + firstPetPath <- ZIO.fromEither(JsonPath.compile("$.vc.pets[0]")) + isEmployedPath <- ZIO.fromEither(JsonPath.compile("$.vc.isEmployed")) + languagesPath <- ZIO.fromEither(JsonPath.compile("$.vc.languages")) + name <- ZIO.fromEither(namePath.read(json)) + age <- ZIO.fromEither(agePath.read(json)) + degree <- ZIO.fromEither(degreePath.read(json)) + pet <- ZIO.fromEither(petPath.read(json)) + firstPet <- ZIO.fromEither(firstPetPath.read(json)) + isEmployed <- ZIO.fromEither(isEmployedPath.read(json)) + languages <- ZIO.fromEither(languagesPath.read(json)) } yield assert(name.asString)(isSome(equalTo("alice"))) && assert(age.asNumber)(isSome(equalTo(Json.Num(42)))) && assert(degree.asNull)(isSome(anything)) @@ -93,14 +93,14 @@ object JsonPathSpec extends ZIOSpecDefault { """.stripMargin for { json <- ZIO.fromEither(jsonStr.fromJson[Json]) - nonExistingPath <- JsonPath.compile("$.vc2.name") - invalidTypeArrayPath <- JsonPath.compile("$.vc.name[0]") - outOfBoundArrayPath <- JsonPath.compile("$.vc.name[5]") - outOfBoundSlicePath <- JsonPath.compile("$.vc.name[1:4]") - exit1 <- nonExistingPath.read(json).exit - exit2 <- invalidTypeArrayPath.read(json).exit - exit3 <- outOfBoundArrayPath.read(json).exit - exit4 <- outOfBoundSlicePath.read(json).exit + nonExistingPath <- ZIO.fromEither(JsonPath.compile("$.vc2.name")) + invalidTypeArrayPath <- ZIO.fromEither(JsonPath.compile("$.vc.name[0]")) + outOfBoundArrayPath <- ZIO.fromEither(JsonPath.compile("$.vc.name[5]")) + outOfBoundSlicePath <- ZIO.fromEither(JsonPath.compile("$.vc.name[1:4]")) + exit1 <- ZIO.fromEither(nonExistingPath.read(json)).exit + exit2 <- ZIO.fromEither(invalidTypeArrayPath.read(json)).exit + exit3 <- ZIO.fromEither(outOfBoundArrayPath.read(json)).exit + exit4 <- ZIO.fromEither(outOfBoundSlicePath.read(json)).exit } yield assert(exit1)(failsWithA[PathNotFound]) && assert(exit2)(failsWithA[PathNotFound]) && assert(exit3)(failsWithA[PathNotFound])