Skip to content

Commit

Permalink
feat: presentation_submission verification
Browse files Browse the repository at this point in the history
Signed-off-by: Pat Losoponkul <[email protected]>

chore: only use claim format value defined in contract

Signed-off-by: Pat Losoponkul <[email protected]>

fix: adapt verification interface

Signed-off-by: Pat Losoponkul <[email protected]>

wip

Signed-off-by: Pat Losoponkul <[email protected]>

feat: ps submission entry processing

Signed-off-by: Pat Losoponkul <[email protected]>

test: sanity check

Signed-off-by: Pat Losoponkul <[email protected]>

wip: verify input constraint

test: matching descriptor and submission

Signed-off-by: Pat Losoponkul <[email protected]>

test: basic matching descriptor

Signed-off-by: Pat Losoponkul <[email protected]>

test: submission id not match

Signed-off-by: Pat Losoponkul <[email protected]>

test: pd unique id test

Signed-off-by: Pat Losoponkul <[email protected]>

test: moar test!!

style: fix and fmt

Signed-off-by: Pat Losoponkul <[email protected]>

wip

test: cleanup testing

tests: nested_path test and multiple descriptors

Signed-off-by: Pat Losoponkul <[email protected]>

test: optional field constraints test

Signed-off-by: Pat Losoponkul <[email protected]>
  • Loading branch information
Pat Losoponkul committed Sep 10, 2024
1 parent 52f361f commit 520edf8
Show file tree
Hide file tree
Showing 12 changed files with 962 additions and 42 deletions.
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ inThisBuild(
"-unchecked",
),
scalacOptions += "-Wunused:all",
scalacOptions += "-Wconf:cat=deprecation:warning,any:error", // "-Wconf:help",
// scalacOptions += "-Wconf:cat=deprecation:warning,any:error", // "-Wconf:help", // TODO: revert before pr
// scalacOptions += "-Yexplicit-nulls",
// scalacOptions += "-Ysafe-init",
// scalacOptions += "-Werror", // <=> "-Xfatal-warnings"
Expand Down Expand Up @@ -808,7 +808,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,235 @@
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,
InvalidSubmissionId,
JsonPathNotFound,
SubmissionNotSatisfyInputDescriptors
}
import org.hyperledger.identus.pollux.prex.PresentationSubmissionError.InvalidNestedPathDescriptorId
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],
)

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 in https://identity.foundation/presentation-exchange/spec/v2.1.1/#submission-requirement-feature
// It is now a simple check that submission satisties 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 fields = descriptor.constraints.fields.getOrElse(Nil)
// all fields need to be valid
ZIO
.foreach(fields) { 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) { filter =>
JsonSchemaValidatorImpl(filter).validate(jsonAtPath.toString())
}
} yield ()
}
.catchAll { errors =>
// if all paths don't satisfy constraints, but optional, then the field is still valid
// https://identity.foundation/presentation-exchange/spec/v2.1.1/#input-evaluation
if field.optional.getOrElse(false)
then ZIO.unit
else ZIO.fail(errors)
}
.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 520edf8

Please sign in to comment.