-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: presentation_submission verification
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
Showing
12 changed files
with
962 additions
and
42 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
31 changes: 31 additions & 0 deletions
31
pollux/prex/src/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmission.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} |
235 changes: 235 additions & 0 deletions
235
...c/main/scala/org/hyperledger/identus/pollux/prex/PresentationSubmissionVerification.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]" | ||
} | ||
] | ||
} | ||
} |
Oops, something went wrong.