Skip to content

Commit

Permalink
feat: presentation_submission validation logic (hyperledger#1332)
Browse files Browse the repository at this point in the history
Signed-off-by: Pat Losoponkul <[email protected]>
  • Loading branch information
patlo-iog authored Sep 12, 2024
1 parent d29eebb commit f80b3c3
Show file tree
Hide file tree
Showing 12 changed files with 1,006 additions and 41 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.hyperledger.identus.pollux.prex

import org.hyperledger.identus.pollux.prex.PresentationDefinitionError.{
DuplicatedDescriptorId,
InvalidFilterJsonPath,
InvalidFilterJsonSchema,
JsonSchemaOptionNotSupported
Expand All @@ -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 =
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <a
* href="https://identity.foundation/presentation-exchange/spec/v2.1.1/#presentation-submission">Presentation
* Definition</a>
*/
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]
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 23 additions & 0 deletions pollux/prex/src/test/resources/ps/basic_presentation.json
Original file line number Diff line number Diff line change
@@ -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]"
}
]
}
}
Loading

0 comments on commit f80b3c3

Please sign in to comment.