diff --git a/build.sbt b/build.sbt index 1d36477448..8da1c85a70 100644 --- a/build.sbt +++ b/build.sbt @@ -124,6 +124,7 @@ lazy val D = new { val jakartaJson: ModuleID = "org.glassfish" % "jakarta.json" % "2.0.1" val ironVC: ModuleID = "com.apicatalog" % "iron-verifiable-credentials" % "0.14.0" val scodecBits: ModuleID = "org.scodec" %% "scodec-bits" % "1.1.38" + val jaywayJsonPath: ModuleID = "com.jayway.jsonpath" % "json-path" % "2.9.0" // https://mvnrepository.com/artifact/org.didcommx/didcomm/0.3.2 val didcommx: ModuleID = "org.didcommx" % "didcomm" % "0.3.2" @@ -200,7 +201,8 @@ lazy val D_SharedJson = new { D.jakartaJson, D.ironVC, D.scodecBits, - D.networkntJsonSchemaValidator + D.networkntJsonSchemaValidator, + D.jaywayJsonPath ) } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala index dbaf3e7676..ec78799452 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala @@ -16,8 +16,7 @@ import org.hyperledger.identus.castor.core.service.DIDService import org.hyperledger.identus.mercury.{AgentPeerService, DidAgent} import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation -import org.hyperledger.identus.pollux.core.model.error.CredentialServiceError -import org.hyperledger.identus.pollux.core.model.error.PresentationError +import org.hyperledger.identus.pollux.core.model.error.{CredentialServiceError, PresentationError} import org.hyperledger.identus.pollux.core.model.DidCommID import org.hyperledger.identus.pollux.core.service.CredentialService import org.hyperledger.identus.pollux.sdjwt.SDJWT.* diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala index 01e73794ce..48a5a5619d 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala @@ -24,8 +24,7 @@ import org.hyperledger.identus.pollux.core.model.CredentialFormat.{AnonCreds, JW import org.hyperledger.identus.pollux.core.model.IssueCredentialRecord.Role import org.hyperledger.identus.pollux.core.service.CredentialService import org.hyperledger.identus.shared.models.{KeyId, WalletAccessContext} -import zio.{URLayer, ZIO, ZLayer} -import zio.Duration +import zio.{Duration, URLayer, ZIO, ZLayer} import scala.language.implicitConversions class IssueControllerImpl( diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala index cd4d35ffbc..b8eebace96 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala @@ -4,7 +4,6 @@ import cats.implicits.* import io.circe.* import io.circe.parser.* import io.circe.syntax.* -import io.circe.Json import org.hyperledger.identus.agent.walletapi.model.{ManagedDIDState, PublicationState} import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService import org.hyperledger.identus.agent.walletapi.storage.GenericSecretStorage diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/model/presentation/PresentationAttachmentSpec.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/model/presentation/PresentationAttachmentSpec.scala index b4e77143e7..725e81271d 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/model/presentation/PresentationAttachmentSpec.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/model/presentation/PresentationAttachmentSpec.scala @@ -6,6 +6,8 @@ import io.circe.Json import munit.* import org.hyperledger.identus.pollux.prex.* +import scala.language.implicitConversions + class PresentationAttachmentSpec extends ZSuite { test("Verifier Request Presentation Attachment") { 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 5789c214c0..7a5b8ac47f 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 @@ -4,12 +4,22 @@ import com.networknt.schema.{JsonSchema, SpecVersion} import io.circe.* import io.circe.generic.semiauto.* import io.circe.Json as CirceJson -import org.hyperledger.identus.shared.json.{JsonInterop, JsonSchemaError, JsonSchemaUtils} +import org.hyperledger.identus.shared.json.{JsonInterop, JsonPath, JsonPathError, JsonSchemaError, JsonSchemaUtils} import zio.* import zio.json.ast.Json as ZioJson -// TODO: define proper type -type JsonPath = String +opaque type JsonPathValue = String + +object JsonPathValue { + given Encoder[JsonPathValue] = Encoder.encodeString + given Decoder[JsonPathValue] = Decoder.decodeString + given Conversion[String, JsonPathValue] = identity + + extension (jpv: JsonPathValue) { + def toJsonPath: IO[JsonPathError, JsonPath] = JsonPath.compile(jpv) + def value: String = jpv + } +} opaque type FieldFilter = ZioJson @@ -29,10 +39,11 @@ object FieldFilter { case class Field( id: Option[String] = None, - path: Seq[JsonPath] = Seq.empty, + path: Seq[JsonPathValue] = Seq.empty, name: Option[String] = None, purpose: Option[String] = None, - filter: Option[FieldFilter] = None + filter: Option[FieldFilter] = None, + optional: Option[Boolean] = None ) object Field { 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 3160d373dc..9b47a914ae 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,10 +1,16 @@ package org.hyperledger.identus.pollux.prex import org.hyperledger.identus.pollux.prex.PresentationDefinitionError.{ + InvalidFilterJsonPath, InvalidFilterJsonSchema, JsonSchemaOptionNotSupported } -import org.hyperledger.identus.shared.json.{JsonSchemaError, JsonSchemaValidator, JsonSchemaValidatorImpl} +import org.hyperledger.identus.shared.json.{ + JsonPathError, + JsonSchemaError, + JsonSchemaValidator, + JsonSchemaValidatorImpl +} import org.hyperledger.identus.shared.models.{Failure, StatusCode} import zio.* @@ -15,6 +21,12 @@ sealed trait PresentationDefinitionError extends Failure { } object PresentationDefinitionError { + final case class InvalidFilterJsonPath(path: String, error: JsonPathError) extends PresentationDefinitionError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = + s"PresentationDefinition input_descriptors path '$path' is not a valid JsonPath" + } + final case class InvalidFilterJsonSchema(json: String, error: JsonSchemaError) extends PresentationDefinitionError { override def statusCode: StatusCode = StatusCode.BadRequest override def userFacingMessage: String = @@ -47,17 +59,28 @@ object PresentationDefinitionValidatorImpl { class PresentationDefinitionValidatorImpl(filterSchemaValidator: JsonSchemaValidator) extends PresentationDefinitionValidator { override def validate(pd: PresentationDefinition): IO[PresentationDefinitionError, Unit] = { - val filters = pd.input_descriptors + val fields = pd.input_descriptors .flatMap(_.constraints.fields) .flatten - .flatMap(_.filter) + + val paths = fields.flatMap(_.path) + val filters = fields.flatMap(_.filter) for { + _ <- validateJsonPaths(paths) _ <- validateFilters(filters) _ <- validateAllowedFilterSchemaKeys(filters) } yield () } + private def validateJsonPaths(paths: Seq[JsonPathValue]): IO[PresentationDefinitionError, Unit] = { + ZIO + .foreach(paths) { path => + path.toJsonPath.mapError(InvalidFilterJsonPath(path.value, _)) + } + .unit + } + // while we use full-blown json-schema library, we limit the schema optiton // to make sure verfier don't go crazy on schema causing problem with holder interoperability // see SDK supported keys https://github.com/hyperledger/identus-edge-agent-sdk-ts/blob/da27890ad4ff3d32576bda8bc99a1185e7239a4c/src/domain/models/VerifiableCredential.ts#L120 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 bd57c15230..108168ae62 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.{ + InvalidFilterJsonPath, InvalidFilterJsonSchema, JsonSchemaOptionNotSupported } @@ -68,9 +69,6 @@ object PresentationDefinitionValidatorSpec extends ZIOSpecDefault { pd <- ZIO .fromEither(decode[ExampleTransportEnvelope](pdJson)) .map(_.presentation_definition) - filters <- ZIO - .succeed(pd.input_descriptors.flatMap(_.constraints.fields.getOrElse(Nil))) - .map(_.flatMap(_.filter)) exit <- validator.validate(pd).exit } yield assert(exit)(failsWithA[InvalidFilterJsonSchema]) }, @@ -103,11 +101,39 @@ object PresentationDefinitionValidatorSpec extends ZIOSpecDefault { pd <- ZIO .fromEither(decode[ExampleTransportEnvelope](pdJson)) .map(_.presentation_definition) - filters <- ZIO - .succeed(pd.input_descriptors.flatMap(_.constraints.fields.getOrElse(Nil))) - .map(_.flatMap(_.filter)) exit <- validator.validate(pd).exit } yield assert(exit)(failsWithA[JsonSchemaOptionNotSupported]) + }, + test("reject when path is not a valid json path") { + val pdJson = + """{ + | "presentation_definition": { + | "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + | "input_descriptors": [ + | { + | "id": "wa_driver_license", + | "name": "Washington State Business License", + | "purpose": "We can only allow licensed Washington State business representatives into the WA Business Conference", + | "constraints": { + | "fields": [ + | { + | "path": ["$$"] + | } + | ] + | } + | } + | ] + | } + |} + """.stripMargin + + for { + validator <- ZIO.service[PresentationDefinitionValidator] + pd <- ZIO + .fromEither(decode[ExampleTransportEnvelope](pdJson)) + .map(_.presentation_definition) + exit <- validator.validate(pd).exit + } yield assert(exit)(failsWithA[InvalidFilterJsonPath]) } ) .provide(PresentationDefinitionValidatorImpl.layer) 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 new file mode 100644 index 0000000000..48770dd784 --- /dev/null +++ b/shared/json/src/main/scala/org/hyperledger/identus/shared/json/JsonPath.scala @@ -0,0 +1,53 @@ +package org.hyperledger.identus.shared.json + +import com.jayway.jsonpath.{InvalidPathException, JsonPath as JaywayJsonPath, PathNotFoundException} +import com.jayway.jsonpath.spi.json.JacksonJsonProvider +import org.hyperledger.identus.shared.models.{Failure, StatusCode} +import zio.* +import zio.json.* +import zio.json.ast.Json + +sealed trait JsonPathError extends Failure { + override def namespace: String = "JsonPathError" +} + +object JsonPathError { + final case class InvalidPathInput(msg: String) extends JsonPathError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"The JsonPath input is not valid: $msg" + } + + final case class PathNotFound(path: String) extends JsonPathError { + override def statusCode: StatusCode = StatusCode.BadRequest + override def userFacingMessage: String = s"The json path '$path' cannot be found in a json" + } +} + +opaque type JsonPath = JaywayJsonPath + +object JsonPath { + def compile(path: String): IO[JsonPathError, JsonPath] = { + ZIO + .attempt(JaywayJsonPath.compile(path)) + .refineOrDie { + case e: IllegalArgumentException => JsonPathError.InvalidPathInput(e.getMessage()) + case e: InvalidPathException => JsonPathError.InvalidPathInput(e.getMessage()) + } + } + + extension (jsonPath: JsonPath) { + def read(json: Json): IO[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()) + } + queriedJsonStr = jsonProvider.toJson(queriedObj) + queriedJson <- ZIO.fromEither(queriedJsonStr.fromJson[Json]).orDieWith(Exception(_)) + } 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 new file mode 100644 index 0000000000..5f4d41c612 --- /dev/null +++ b/shared/json/src/test/scala/org/hyperledger/identus/shared/json/JsonPathSpec.scala @@ -0,0 +1,111 @@ +package org.hyperledger.identus.shared.json + +import org.hyperledger.identus.shared.json.JsonPathError.{InvalidPathInput, PathNotFound} +import zio.* +import zio.json.* +import zio.json.ast.Json +import zio.test.* +import zio.test.Assertion.* + +object JsonPathSpec extends ZIOSpecDefault { + + override def spec = suite("JsonPathSpec")( + test("sucessfully compile a valid json path") { + val paths = Seq( + "$.store.book[*].author", + "$..author", + "$.store.*", + "$.store..price", + "$..book[2]", + "$..book[-1:]", + "$..book[0,1]", + "$..book[:2]", + "$..book[?(@.isbn)]", + "$..book[?(@.price<10)]", + "$..book[?(@.price==8.95)]", + "$..book[?(@.price<30 && @.category==\"fiction\")]", + "$..*", + "$['foo']['bar']" + ) + ZIO + .foreach(paths)(JsonPath.compile) + .as(assertCompletes) + }, + test("do not accept invalid json path") { + val paths = Seq( + "", + " ", + " $ ", + "$$", + "hello world", + ) + ZIO + .foreach(paths)(p => JsonPath.compile(p).flip) + .map { errors => + assert(errors)(forall(isSubtype[InvalidPathInput](anything))) + } + }, + test("query valid path inside json structure") { + val jsonStr = + """{ + | "vc": { + | "name": "alice", + | "age": 42, + | "degree": null, + | "pets": ["dog", "cat"], + | "isEmployed": false, + | "languages": {"english": "native", "chinese": "fluent"} + | } + |} + """.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) + } yield assert(name.asString)(isSome(equalTo("alice"))) + && assert(age.asNumber)(isSome(equalTo(Json.Num(42)))) + && assert(degree.asNull)(isSome(anything)) + && assert(pet.asArray)(isSome(hasSize((equalTo(2))))) + && assert(firstPet.asString)(isSome(equalTo("dog"))) + && assert(isEmployed.asBoolean)(isSome(isFalse)) + && assert(languages.asObject)(isSome(anything)) + }, + test("query invalid path inside json structure") { + val jsonStr = + """{ + | "vc": { + | "name": "alice", + | "pets": ["dog", "cat"] + | } + |} + """.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 + } yield assert(exit1)(failsWithA[PathNotFound]) + && assert(exit2)(failsWithA[PathNotFound]) + && assert(exit3)(failsWithA[PathNotFound]) + && assert(exit4)(failsWithA[PathNotFound]) + } + ) + +}