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: presentation_submission validation logic #1332

Merged
merged 7 commits into from
Sep 12, 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
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.*
yshyn-iohk marked this conversation as resolved.
Show resolved Hide resolved
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
Loading