From 8b97089fed0996a200a5c160e29c277a92b95c11 Mon Sep 17 00:00:00 2001 From: shotexa Date: Wed, 6 Mar 2024 03:52:22 +0400 Subject: [PATCH] feat(pollux): check verification status on presentation verification (#917) Signed-off-by: Shota Jolbordi --- build.sbt | 28 +++-- .../core/model/error/PresentationError.scala | 1 + .../core/service/CredentialServiceImpl.scala | 2 +- .../io/iohk/atala/pollux/vc/jwt/DidJWT.scala | 8 +- .../atala/pollux/vc/jwt/MultiBaseString.scala | 16 +++ .../io/iohk/atala/pollux/vc/jwt/Proof.scala | 61 ++++++++- .../vc/jwt/VerifiableCredentialPayload.scala | 118 ++++++++++++++++-- .../jwt/VerifiablePresentationPayload.scala | 24 +++- .../JwtPresentationVerificationDemo.scala | 3 +- .../pollux/vc/jwt/JWTVerificationTest.scala | 89 ++++++++++++- .../io/iohk/atala/agent/server/Modules.scala | 2 +- .../atala/agent/server/PrismAgentApp.scala | 7 +- .../server/jobs/PresentBackgroundJobs.scala | 31 ++++- .../agent/server/jobs/StatusListJobs.scala | 5 +- .../http/StatusListCredential.scala | 9 ++ .../shared/http/GenericUriResolver.scala | 52 ++++++++ .../atala-performance-tests-k6/.npmrc | 1 + .../atala-performance-tests-k6/.yarnrc | 1 + .../atala-performance-tests-k6/README.md | 3 + .../atala-performance-tests-k6/package.json | 2 +- 20 files changed, 411 insertions(+), 52 deletions(-) create mode 100644 shared/src/main/scala/io/iohk/atala/shared/http/GenericUriResolver.scala create mode 100644 tests/performance-tests/atala-performance-tests-k6/.npmrc create mode 100644 tests/performance-tests/atala-performance-tests-k6/.yarnrc diff --git a/build.sbt b/build.sbt index 51ad42d7c4..1e369601c1 100644 --- a/build.sbt +++ b/build.sbt @@ -57,7 +57,7 @@ lazy val V = new { val zioMetricsConnector = "2.1.0" val zioMock = "1.0.0-RC11" val mockito = "3.2.16.0" - val monocle = "3.1.0" + val monocle = "3.1.0" // https://mvnrepository.com/artifact/io.circe/circe-core val circe = "0.14.6" @@ -107,6 +107,7 @@ lazy val D = new { val tapirPrometheusMetrics: ModuleID = "com.softwaremill.sttp.tapir" %% "tapir-prometheus-metrics" % V.tapir val micrometer: ModuleID = "io.micrometer" % "micrometer-registry-prometheus" % V.micrometer val micrometerPrometheusRegistry = "io.micrometer" % "micrometer-core" % V.micrometer + val scalaUri = "io.lemonlabs" %% "scala-uri" % V.scalaUri val zioConfig: ModuleID = "dev.zio" %% "zio-config" % V.zioConfig val zioConfigMagnolia: ModuleID = "dev.zio" %% "zio-config-magnolia" % V.zioConfig @@ -154,7 +155,7 @@ lazy val D = new { val zioTestMagnolia: ModuleID = "dev.zio" %% "zio-test-magnolia" % V.zio % Test val zioMock: ModuleID = "dev.zio" %% "zio-mock" % V.zioMock val mockito: ModuleID = "org.scalatestplus" %% "mockito-4-11" % V.mockito % Test - val monocle: ModuleID = "dev.optics" %% "monocle-core" % V.monocle % Test + val monocle: ModuleID = "dev.optics" %% "monocle-core" % V.monocle % Test val monocleMacro: ModuleID = "dev.optics" %% "monocle-macro" % V.monocle % Test // LIST of Dependencies @@ -168,12 +169,17 @@ lazy val D_Shared = new { D.typesafeConfig, D.scalaPbGrpc, D.zio, + D.zioHttp, + D.scalaUri, // FIXME: split shared DB stuff as subproject? D.doobieHikari, D.doobiePostgres, D.zioCatsInterop, D.jsonCanonicalization, - D.scodecBits + D.scodecBits, + D.circeCore, + D.circeGeneric, + D.circeParser, ) } @@ -210,8 +216,6 @@ lazy val D_Connect = new { lazy val D_Castor = new { - val scalaUri = "io.lemonlabs" %% "scala-uri" % V.scalaUri - // We have to exclude bouncycastle since for some reason bitcoinj depends on bouncycastle jdk15to18 // (i.e. JDK 1.5 to 1.8), but we are using JDK 11 val prismCrypto = "io.iohk.atala" % "prism-crypto-jvm" % V.prismSdk excludeAll @@ -228,11 +232,7 @@ lazy val D_Castor = new { D.zioMock, D.zioTestSbt, D.zioTestMagnolia, - D.circeCore, - D.circeGeneric, - D.circeParser, prismIdentity, - scalaUri ) // Project Dependencies @@ -316,9 +316,8 @@ lazy val D_Pollux_VC_JWT = new { // Dependency Modules val zioDependencies: Seq[ModuleID] = Seq(zio, zioPrelude, zioTest, zioTestSbt, zioTestMagnolia) - val circeDependencies: Seq[ModuleID] = Seq(D.circeCore, D.circeGeneric, D.circeParser) val baseDependencies: Seq[ModuleID] = - circeDependencies ++ zioDependencies :+ D.jwtCirce :+ circeJsonSchema :+ networkntJsonSchemaValidator :+ D.nimbusJwt :+ scalaTest + zioDependencies :+ D.jwtCirce :+ circeJsonSchema :+ networkntJsonSchemaValidator :+ D.nimbusJwt :+ scalaTest // Project Dependencies lazy val polluxVcJwtDependencies: Seq[ModuleID] = baseDependencies @@ -412,7 +411,12 @@ lazy val D_PrismAgent = new { lazy val iamDependencies: Seq[ModuleID] = Seq(keycloakAuthz, D.jwtCirce) lazy val serverDependencies: Seq[ModuleID] = - baseDependencies ++ tapirDependencies ++ postgresDependencies ++ Seq(D.zioMock, D.mockito, D.monocle, D.monocleMacro) + baseDependencies ++ tapirDependencies ++ postgresDependencies ++ Seq( + D.zioMock, + D.mockito, + D.monocle, + D.monocleMacro + ) } publish / skip := true diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/PresentationError.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/PresentationError.scala index 48896a7409..064d6fa9d3 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/PresentationError.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/PresentationError.scala @@ -16,5 +16,6 @@ object PresentationError { final case class HolderBindingError(msg: String) extends PresentationError object MissingCredential extends PresentationError object MissingCredentialFormat extends PresentationError + final case class UnsupportedCredentialFormat(vcFormat: String) extends PresentationError } diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceImpl.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceImpl.scala index 23e428224e..a580e0bdfb 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceImpl.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceImpl.scala @@ -1246,7 +1246,7 @@ private class CredentialServiceImpl( verifyDates = false, leeway = Duration.Zero ) - )(didResolver)(clock) + )(didResolver, (_: String) => ZIO.succeed(""))(clock) .mapError(errors => CredentialRequestValidationError(s"JWT presentation verification failed: $errors")) result <- verificationResult match diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/DidJWT.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/DidJWT.scala index c65f67545c..13f750a3b2 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/DidJWT.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/DidJWT.scala @@ -37,7 +37,7 @@ trait Signer { } -class ES256Signer(privateKey: PrivateKey) extends Signer with EddsaJcs2022ProofGenerator { +class ES256Signer(privateKey: PrivateKey) extends Signer { val algorithm: JwtECDSAAlgorithm = JwtAlgorithm.ES256 private val provider = BouncyCastleProviderSingleton.getInstance Security.addProvider(provider) @@ -45,14 +45,14 @@ class ES256Signer(privateKey: PrivateKey) extends Signer with EddsaJcs2022ProofG override def encode(claim: Json): JWT = JWT(JwtCirce.encode(claim, privateKey, algorithm)) override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { - generateProof(payload, privateKey, pk) + EddsaJcs2022ProofGenerator.generateProof(payload, privateKey, pk) } } // works with java 7, 8, 11 & bouncycastle provider // https://connect2id.com/products/nimbus-jose-jwt/jca-algorithm-support#alg-support-table -class ES256KSigner(privateKey: PrivateKey) extends Signer with EddsaJcs2022ProofGenerator { +class ES256KSigner(privateKey: PrivateKey) extends Signer { lazy val signer: ECDSASigner = { val ecdsaSigner = ECDSASigner(privateKey, Curve.SECP256K1) val bouncyCastleProvider = BouncyCastleProviderSingleton.getInstance @@ -61,7 +61,7 @@ class ES256KSigner(privateKey: PrivateKey) extends Signer with EddsaJcs2022Proof } override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { - generateProof(payload, privateKey, pk) + EddsaJcs2022ProofGenerator.generateProof(payload, privateKey, pk) } override def encode(claim: Json): JWT = { diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiBaseString.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiBaseString.scala index faa08c1605..d1c881e578 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiBaseString.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiBaseString.scala @@ -1,9 +1,17 @@ package io.iohk.atala.pollux.vc.jwt import io.circe.* +import io.iohk.atala.shared.utils.Base64Utils +import scodec.bits.ByteVector case class MultiBaseString(header: MultiBaseString.Header, data: String) { def toMultiBaseString: String = s"${header.value}$data" + + def getBytes: Either[String, Array[Byte]] = header match { + case MultiBaseString.Header.Base64Url => Right(Base64Utils.decodeURL(data)) + case MultiBaseString.Header.Base58Btc => + ByteVector.fromBase58(data).map(_.toArray).toRight(s"Invalid base58 string: $data") + } } object MultiBaseString { @@ -12,6 +20,14 @@ object MultiBaseString { case Base58Btc extends Header('z') } + def fromString(str: String): Either[String, MultiBaseString] = { + val header = Header.fromValue(str.head) + header match { + case Some(value) => Right(MultiBaseString(value, str.tail)) + case None => Left(s"$str - is not a multi base string") + } + } + object Header { def fromValue(value: Char): Option[Header] = value match { case 'u' => Some(Header.Base64Url) diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Proof.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Proof.scala index 5ff5e4ac03..80b53ab5b0 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Proof.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Proof.scala @@ -3,13 +3,15 @@ package io.iohk.atala.pollux.vc.jwt import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import io.circe.* import io.circe.syntax.* - +import cats.implicits.* import java.time.{Instant, ZoneOffset} import zio.* import io.iohk.atala.shared.utils.Json as JsonUtils import io.iohk.atala.shared.utils.Base64Utils import scodec.bits.ByteVector +import scala.util.Try import java.security.* +import java.security.spec.X509EncodedKeySpec sealed trait Proof { val id: Option[String] = None @@ -24,13 +26,31 @@ sealed trait Proof { val nonce: Option[String] = None } -trait EddsaJcs2022ProofGenerator { +object Proof { + given decodeProof: Decoder[Proof] = new Decoder[Proof] { + final def apply(c: HCursor): Decoder.Result[Proof] = { + val decoders: List[Decoder[Proof]] = List( + Decoder[EddsaJcs2022Proof].widen + // Note: Add another proof types here when available + ) + + decoders.foldLeft( + Left[DecodingFailure, Proof](DecodingFailure("Cannot decode as Proof", c.history)): Decoder.Result[Proof] + ) { (acc, decoder) => + acc.orElse(decoder.tryDecode(c)) + } + } + } +} + +object EddsaJcs2022ProofGenerator { private val provider = BouncyCastleProviderSingleton.getInstance def generateProof(payload: Json, sk: PrivateKey, pk: PublicKey): Task[EddsaJcs2022Proof] = { for { canonicalizedJsonString <- ZIO.fromEither(JsonUtils.canonicalizeToJcs(payload.spaces2)) canonicalizedJson <- ZIO.fromEither(parser.parse(canonicalizedJsonString)) - signature = sign(sk, canonicalizedJson.noSpaces.getBytes) + dataToSign = canonicalizedJson.noSpaces.getBytes + signature = sign(sk, dataToSign) base58BtsEncodedSignature = MultiBaseString( header = MultiBaseString.Header.Base58Btc, data = ByteVector.view(signature).toBase58 @@ -50,6 +70,28 @@ trait EddsaJcs2022ProofGenerator { ) } + def verifyProof(payload: Json, proofValue: String, pk: MultiKey): Task[Boolean] = { + + val res = for { + canonicalizedJsonString <- ZIO + .fromEither(JsonUtils.canonicalizeToJcs(payload.spaces2)) + .mapError(_.getMessage) + canonicalizedJson <- ZIO + .fromEither(parser.parse(canonicalizedJsonString)) + .mapError(_.getMessage) + dataToVerify = canonicalizedJson.noSpaces.getBytes + signature <- ZIO.fromEither(MultiBaseString.fromString(proofValue).flatMap(_.getBytes)) + publicKeyBytes <- ZIO.fromEither( + pk.publicKeyMultibase.toRight("No public key provided inside MultiKey").flatMap(_.getBytes) + ) + javaPublicKey <- ZIO.fromEither(recoverPublicKey(publicKeyBytes)) + isValid = verify(javaPublicKey, signature, dataToVerify) + + } yield isValid + + res.mapError(e => Throwable(e)) + } + private def sign(privateKey: PrivateKey, data: Array[Byte]): Array[Byte] = { val signer = Signature.getInstance("SHA256withECDSA", provider) @@ -57,6 +99,19 @@ trait EddsaJcs2022ProofGenerator { signer.update(data) signer.sign() } + + private def recoverPublicKey(pkBytes: Array[Byte]): Either[String, PublicKey] = { + val keyFactory = KeyFactory.getInstance("EC", provider) + val x509KeySpec = X509EncodedKeySpec(pkBytes) + Try(keyFactory.generatePublic(x509KeySpec)).toEither.left.map(_.getMessage) + } + + private def verify(publicKey: PublicKey, signature: Array[Byte], data: Array[Byte]): Boolean = { + val verifier = Signature.getInstance("SHA256withECDSA", provider) + verifier.initVerify(publicKey) + verifier.update(data) + verifier.verify(signature) + } } case class EddsaJcs2022Proof(proofValue: String, verificationMethod: String, maybeCreated: Option[Instant]) extends Proof { diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiableCredentialPayload.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiableCredentialPayload.scala index 5b0253b653..c37183b110 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiableCredentialPayload.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiableCredentialPayload.scala @@ -6,15 +6,17 @@ import io.circe.parser.decode import io.circe.syntax.* import io.circe.{CursorOp, Decoder, DecodingFailure, Encoder, HCursor, Json} import io.iohk.atala.castor.core.model.did.VerificationRelationship +import io.iohk.atala.pollux.vc.jwt.revocation.BitString import io.iohk.atala.pollux.vc.jwt.schema.{SchemaResolver, SchemaValidator} +import io.iohk.atala.shared.http.UriResolver import pdi.jwt.* import zio.prelude.* import zio.* + import java.security.PublicKey import java.time.temporal.TemporalAmount import java.time.{Clock, Instant} import scala.util.Try -import io.iohk.atala.shared.utils.Json as JsonUtils opaque type DID = String object DID { @@ -634,15 +636,72 @@ object CredentialVerification { * the result of the validation. */ def verify(verifiableCredentialPayload: VerifiableCredentialPayload, options: CredentialVerificationOptions)( - didResolver: DidResolver + didResolver: DidResolver, + uriResolver: UriResolver )(implicit clock: Clock): IO[String, Validation[String, Unit]] = { verifiableCredentialPayload match { - case (w3cVerifiableCredentialPayload: W3cVerifiableCredentialPayload) => - W3CCredential.verify(w3cVerifiableCredentialPayload, options)(didResolver) - case (jwtVerifiableCredentialPayload: JwtVerifiableCredentialPayload) => - JwtCredential.verify(jwtVerifiableCredentialPayload, options)(didResolver) + case w3cVerifiableCredentialPayload: W3cVerifiableCredentialPayload => + W3CCredential.verify(w3cVerifiableCredentialPayload, options)(didResolver, uriResolver) + case jwtVerifiableCredentialPayload: JwtVerifiableCredentialPayload => + JwtCredential.verify(jwtVerifiableCredentialPayload, options)(didResolver, uriResolver) } } + + def verifyCredentialStatus( + credentialStatus: CredentialStatus + )(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { + + val res = for { + statusListString <- uriResolver + .resolve(credentialStatus.statusListCredential) + .mapError(err => s"Could not resolve status list credential: $err") + _ <- ZIO.logInfo("Credential status: " + credentialStatus) + vcStatusListCredJson <- ZIO + .fromEither(io.circe.parser.parse(statusListString)) + .mapError(err => s"Could not parse status list credential as Json string: $err") + proof <- ZIO + .fromEither(vcStatusListCredJson.hcursor.downField("proof").as[Proof]) + .mapError(err => s"Could not extract proof from status list credential: $err") + + // Verify proof + verified <- proof match + case EddsaJcs2022Proof(proofValue, verificationMethod, maybeCreated) => + val publicKeyMultiBaseEffect = uriResolver + .resolve(verificationMethod) + .mapError(_.toThrowable) + .flatMap { jsonResponse => + ZIO.fromEither(io.circe.parser.decode[MultiKey](jsonResponse)).mapError(_.getCause) + } + .mapError(_.getMessage) + + for { + publicKeyMultiBase <- publicKeyMultiBaseEffect + statusListCredJsonWithoutProof = vcStatusListCredJson.hcursor.downField("proof").delete.top.get + verified <- EddsaJcs2022ProofGenerator + .verifyProof(statusListCredJsonWithoutProof, proofValue, publicKeyMultiBase) + .mapError(_.getMessage) + } yield verified + + // Note: add other proof types here when available + case _ => ZIO.fail(s"Unsupported proof type - ${proof.`type`}") + + proofVerificationValidation = + if (verified) Validation.unit else Validation.fail("Could not verify status list credential proof") + + // Check revocation status in the list by index + encodedBitStringEither = vcStatusListCredJson.hcursor + .downField("credentialSubject") + .as[Json] + .flatMap(_.hcursor.downField("encodedList").as[String]) + encodedBitString <- ZIO.fromEither(encodedBitStringEither).mapError(_.getMessage) + bitString <- BitString.valueOf(encodedBitString).mapError(_.message) + isRevoked <- bitString.isRevoked(credentialStatus.statusListIndex).mapError(_.message) + revocationValidation = if (isRevoked) Validation.fail("Credential is revoked") else Validation.unit + + } yield Validation.validateWith(proofVerificationValidation, revocationValidation)((a, _) => a) + + res + } } object JwtCredential { @@ -726,11 +785,14 @@ object JwtCredential { } def verify(jwt: JwtVerifiableCredentialPayload, options: CredentialVerification.CredentialVerificationOptions)( - didResolver: DidResolver - )(implicit clock: Clock): IO[String, Validation[String, Unit]] = verify(jwt.jwt, options)(didResolver)(clock) + didResolver: DidResolver, + uriResolver: UriResolver + )(implicit clock: Clock): IO[String, Validation[String, Unit]] = + verify(jwt.jwt, options)(didResolver, uriResolver)(clock) def verify(jwt: JWT, options: CredentialVerification.CredentialVerificationOptions)( - didResolver: DidResolver + didResolver: DidResolver, + uriResolver: UriResolver )(implicit clock: Clock): IO[String, Validation[String, Unit]] = { for { signatureValidation <- @@ -739,7 +801,27 @@ object JwtCredential { dateVerification <- ZIO.succeed( if (options.verifyDates) then verifyDates(jwt, options.leeway) else Validation.unit ) - } yield Validation.validateWith(signatureValidation, dateVerification)((a, _) => a) + revocationVerification <- verifyRevocationStatusJwt(jwt)(uriResolver) + + } yield Validation.validateWith(signatureValidation, dateVerification, revocationVerification)((a, _, _) => a) + } + + private def verifyRevocationStatusJwt(jwt: JWT)(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { + val decodeJWT = + ZIO + .fromTry(JwtCirce.decodeRaw(jwt.value, options = JwtOptions(false, false, false))) + .mapError(_.getMessage) + + val res = for { + decodedJWT <- decodeJWT + jwtCredentialPayload <- ZIO.fromEither(decode[JwtCredentialPayload](decodedJWT)).mapError(_.getMessage) + credentialStatus = jwtCredentialPayload.vc.maybeCredentialStatus + result = credentialStatus.fold(ZIO.succeed(Validation.unit))(status => + CredentialVerification.verifyCredentialStatus(status)(uriResolver) + ) + } yield result + + res.flatten } } @@ -789,8 +871,19 @@ object W3CCredential { ) } + private def verifyRevocationStatusW3c( + w3cPayload: W3cVerifiableCredentialPayload, + )(uriResolver: UriResolver): IO[String, Validation[String, Unit]] = { + // If credential does not have credential status list, it does not support revocation + // and we assume revocation status is valid. + w3cPayload.payload.maybeCredentialStatus.fold(ZIO.succeed(Validation.unit))(status => + CredentialVerification.verifyCredentialStatus(status)(uriResolver) + ) + } + def verify(w3cPayload: W3cVerifiableCredentialPayload, options: CredentialVerification.CredentialVerificationOptions)( - didResolver: DidResolver + didResolver: DidResolver, + uriResolver: UriResolver )(implicit clock: Clock): IO[String, Validation[String, Unit]] = { for { signatureValidation <- @@ -799,6 +892,7 @@ object W3CCredential { dateVerification <- ZIO.succeed( if (options.verifyDates) then verifyDates(w3cPayload, options.leeway) else Validation.unit ) - } yield Validation.validateWith(signatureValidation, dateVerification)((a, _) => a) + revocationVerification <- verifyRevocationStatusW3c(w3cPayload)(uriResolver) + } yield Validation.validateWith(signatureValidation, dateVerification, revocationVerification)((a, _, _) => a) } } diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiablePresentationPayload.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiablePresentationPayload.scala index ad8211ad58..eaeae39529 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiablePresentationPayload.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/VerifiablePresentationPayload.scala @@ -6,9 +6,11 @@ import io.circe.generic.auto.* import io.circe.parser.decode import io.circe.syntax.* import io.iohk.atala.castor.core.model.did.VerificationRelationship +import io.iohk.atala.shared.http.UriResolver import pdi.jwt.{JwtCirce, JwtOptions} import zio.* import zio.prelude.* + import java.security.PublicKey import java.time.temporal.TemporalAmount import java.time.{Clock, Instant} @@ -365,13 +367,17 @@ object JwtPresentation { def validateEnclosedCredentials( jwt: JWT, options: CredentialVerification.CredentialVerificationOptions - )(didResolver: DidResolver)(implicit clock: Clock): IO[List[String], Validation[String, Unit]] = { + )(didResolver: DidResolver, uriResolver: UriResolver)(implicit + clock: Clock + ): IO[List[String], Validation[String, Unit]] = { val validateJwtPresentation = Validation.fromTry(decodeJwt(jwt)).mapError(_.toString) val credentialValidationZIO = ValidationUtils.foreach( validateJwtPresentation - .map(validJwtPresentation => validateCredentials(validJwtPresentation, options)(didResolver)(clock)) + .map(validJwtPresentation => + validateCredentials(validJwtPresentation, options)(didResolver, uriResolver)(clock) + ) )(identity) credentialValidationZIO.map(validCredentialValidations => { @@ -386,9 +392,11 @@ object JwtPresentation { def validateCredentials( decodedJwtPresentation: JwtPresentationPayload, options: CredentialVerification.CredentialVerificationOptions - )(didResolver: DidResolver)(implicit clock: Clock): ZIO[Any, List[String], IndexedSeq[Validation[String, Unit]]] = { + )(didResolver: DidResolver, uriResolver: UriResolver)(implicit + clock: Clock + ): ZIO[Any, List[String], IndexedSeq[Validation[String, Unit]]] = { ZIO.validatePar(decodedJwtPresentation.vp.verifiableCredential) { a => - CredentialVerification.verify(a, options)(didResolver)(clock) + CredentialVerification.verify(a, options)(didResolver, uriResolver)(clock) } } @@ -564,8 +572,10 @@ object JwtPresentation { * the result of the validation. */ def verify(jwt: JWT, options: PresentationVerificationOptions)( - didResolver: DidResolver + didResolver: DidResolver, + uriResolver: UriResolver )(implicit clock: Clock): IO[List[String], Validation[String, Unit]] = { + // TODO: verify revocation status of credentials inside the presentation for { signatureValidation <- if (options.verifySignature) then @@ -579,7 +589,9 @@ object JwtPresentation { ) credentialVerification <- options.maybeCredentialOptions - .map(credentialOptions => validateEnclosedCredentials(jwt, credentialOptions)(didResolver)(clock)) + .map(credentialOptions => + validateEnclosedCredentials(jwt, credentialOptions)(didResolver, uriResolver)(clock) + ) .getOrElse(ZIO.succeed(Validation.unit)) } yield Validation.validateWith( signatureValidation, diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationVerificationDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationVerificationDemo.scala index c62094c94b..aed191ac15 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationVerificationDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationVerificationDemo.scala @@ -265,7 +265,8 @@ object JwtPresentationVerificationDemo extends ZIOAppDefault { Some(CredentialVerification.CredentialVerificationOptions(verifySignature = true, verifyDates = true)) ) )( - DidResolverTest() + DidResolverTest(), + (_: String) => ZIO.succeed("") )(clock) _ <- printLine(s"W3C IS VALID?: $w3cSignatureValidationResult") _ <- printLine(s"JWT IS VALID?: $jwtSignatureValidationResult") diff --git a/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/JWTVerificationTest.scala b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/JWTVerificationTest.scala index 0e45138ed6..7f096d5b97 100644 --- a/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/JWTVerificationTest.scala +++ b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/JWTVerificationTest.scala @@ -10,7 +10,7 @@ import io.iohk.atala.pollux.vc.jwt.CredentialPayload.Implicits.* import zio.* import zio.test.* import zio.test.Assertion.* - +import io.iohk.atala.shared.http.* import java.security.Security import java.time.Instant @@ -32,6 +32,36 @@ object JWTVerificationTest extends ZIOSpecDefault { ) } + private val statusListCredentialString = """ + |{ + | "proof" : { + | "type" : "DataIntegrityProof", + | "proofPurpose" : "assertionMethod", + | "verificationMethod" : "data:application/json;base64,eyJAY29udGV4dCI6WyJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L211bHRpa2V5L3YxIl0sInR5cGUiOiJNdWx0aWtleSIsInB1YmxpY0tleU11bHRpYmFzZSI6InVNRll3RUFZSEtvWkl6ajBDQVFZRks0RUVBQW9EUWdBRUNYSUZsMlIxOGFtZUxELXlrU09HS1FvQ0JWYkZNNW91bGtjMnZJckp0UzRQWkJnMkxyNEQzUFdYR2xHTXB1aHdwSk84MEFpdzFXeVVHT1hONkJqSlFBPT0ifQ==", + | "created" : "2024-03-04T14:44:43.867542Z", + | "proofValue" : "zAN1rKqPFt7JayDWWD4Gu7HRsNVrgqHxMhKmYT5AE1FYD5a2zaM8G4WRPBmss9M2h3J5f56sunDFbxJVuDGB8qndknijyBcqr3", + | "cryptoSuite" : "eddsa-jcs-2022" + | }, + | "@context" : [ + | "https://www.w3.org/2018/credentials/v1", + | "https://w3id.org/vc/status-list/2021/v1" + | ], + | "type" : [ + | "VerifiableCredential", + | "StatusList2021Credential" + | ], + | "id" : "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9", + | "issuer" : "did:prism:462c4811bf61d7de25b3baf86c5d2f0609b4debe53792d297bf612269bf8593a", + | "issuanceDate" : 1709563483, + | "credentialSubject" : { + | "id" : "", + | "type" : "StatusList2021", + | "statusPurpose" : "Revocation", + | "encodedList" : "H4sIAAAAAAAA_-3BMQ0AAAACIGf_0MbwARoAAAAAAAAAAAAAAAAAAADgbbmHB0sAQAAA" + | } + |} + |""".stripMargin + private def createJwtCredential(issuer: IssuerWithKey): JWT = { val jwtCredentialNbf = Instant.parse("2010-01-01T00:00:00Z") // ISSUANCE DATE val jwtCredentialExp = Instant.parse("2010-01-12T00:00:00Z") // EXPIRATION DATE @@ -95,6 +125,63 @@ object JWTVerificationTest extends ZIOSpecDefault { } override def spec = suite("JWTVerificationSpec")( + test("validate status list credential proof and revocation status by index") { + val statusList: CredentialStatus = CredentialStatus( + id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#1", + statusPurpose = StatusPurpose.Revocation, + `type` = "StatusList2021Entry", + statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9", + statusListIndex = 2 + ) + + val urlResolver = new UriResolver { + override def resolve(uri: String): IO[GenericUriResolverError, String] = { + ZIO.succeed(statusListCredentialString) + } + } + + val genericUriResolver = GenericUriResolver( + Map( + "data" -> DataUrlResolver(), + "http" -> urlResolver, + "https" -> urlResolver + ) + ) + for { + validation <- CredentialVerification.verifyCredentialStatus(statusList)(genericUriResolver) + } yield assertTrue(validation.fold(_ => false, _ => true)) + }, + test("fail verification if proof is valid but credential is revoked at the give status list index") { + val statusList: CredentialStatus = CredentialStatus( + id = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9#1", + statusPurpose = StatusPurpose.Revocation, + `type` = "StatusList2021Entry", + statusListCredential = "http://localhost:8085/credential-status/664382dc-9e6d-4d0c-99d1-85e2c74eb5e9", + statusListIndex = 1 + ) + + val urlResolver = new UriResolver { + override def resolve(uri: String): IO[GenericUriResolverError, String] = { + ZIO.succeed(statusListCredentialString) + } + } + + val genericUriResolver = GenericUriResolver( + Map( + "data" -> DataUrlResolver(), + "http" -> urlResolver, + "https" -> urlResolver + ) + ) + for { + validation <- CredentialVerification.verifyCredentialStatus(statusList)(genericUriResolver) + } yield assertTrue( + validation.fold( + chunk => chunk.length == 1 && chunk.head.contentEquals("Credential is revoked"), + _ => false + ) + ) + }, test("validate PrismDID issued JWT VC using verification publicKeys") { val issuer = createUser(DID("did:prism:issuer")) val jwtCredential = createJwtCredential(issuer) diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala index 5a710cf4e3..f0e5553cc3 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala @@ -58,7 +58,7 @@ object SystemModule { ) } - val zioHttpClientLayer = { + val zioHttpClientLayer: ZLayer[Any, Throwable, Client] = { import zio.http.netty.NettyConfig import zio.http.{ConnectionPoolConfig, DnsResolver, ZClient} (ZLayer.fromZIO( diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/PrismAgentApp.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/PrismAgentApp.scala index a39e7aed6b..9e51700488 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/PrismAgentApp.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/PrismAgentApp.scala @@ -100,9 +100,10 @@ object PrismAgentApp { : ZIO[CredentialStatusListService & DIDService & ManagedDIDService & AppConfig, Throwable, Unit] = { for { config <- ZIO.service[AppConfig] - _ <- StatusListJobs.syncRevocationStatuses.repeat( - Schedule.spaced(config.pollux.syncRevocationStatusesBgJobRecurrenceDelay) - ) // TODO make it configurable + _ <- StatusListJobs.syncRevocationStatuses + .repeat( + Schedule.spaced(config.pollux.syncRevocationStatusesBgJobRecurrenceDelay) + ) } yield () } diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/PresentBackgroundJobs.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/PresentBackgroundJobs.scala index b5e48c21db..9eb3f15697 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/PresentBackgroundJobs.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/PresentBackgroundJobs.scala @@ -28,9 +28,12 @@ import zio.prelude.ZValidation.* import io.iohk.atala.agent.walletapi.storage.DIDNonSecretStorage import io.iohk.atala.agent.walletapi.model.error.DIDSecretStorageError.WalletNotFoundError import io.iohk.atala.resolvers.DIDResolver +import io.iohk.atala.shared.models.WalletAccessContext + import java.time.{Clock, Instant, ZoneId} import io.iohk.atala.castor.core.service.DIDService import io.iohk.atala.agent.walletapi.service.ManagedDIDService +import io.iohk.atala.shared.http.* object PresentBackgroundJobs extends BackgroundJobsHelper { val presentProofExchanges = { @@ -384,6 +387,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { didResolverService <- ZIO.service[JwtDidResolver] credentialsValidationResult <- p.attachments.head.data match { case Base64(data) => + // JWT verifiable presentation decided frin base64 val base64Decoded = new String(java.util.Base64.getDecoder().decode(data)) val maybePresentationOptions : Either[PresentationError, Option[io.iohk.atala.pollux.core.model.presentation.Options]] = @@ -409,7 +413,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { .getOrElse(Right(None)) ) .getOrElse(Left(UnexpectedError("RequestPresentation NotFound"))) - for { + val presentationValidationResult = for { _ <- ZIO.fromEither(maybePresentationOptions.map { case Some(options) => JwtPresentation.validatePresentation( @@ -425,17 +429,38 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { // https://www.w3.org/TR/vc-data-model/#proofs-signatures-0 // A proof is typically attached to a verifiable presentation for authentication purposes // and to a verifiable credential as a method of assertion. + httpLayer <- ZIO.service[HttpClient] + httpUrlResolver = new UriResolver { + override def resolve(uri: String): IO[GenericUriResolverError, String] = { + val res = HttpClient + .get(uri) + .map(x => x.bodyAsString) + .provideSomeLayer(ZLayer.succeed(httpLayer)) + res.mapError(err => SchemaSpecificResolutionError("http", err)) + } + } + genericUriResolver = GenericUriResolver( + Map( + "data" -> DataUrlResolver(), + "http" -> httpUrlResolver, + "https" -> httpUrlResolver + ) + ) result <- JwtPresentation .verify( JWT(base64Decoded), verificationConfig.toPresentationVerificationOptions() - )(didResolverService)(clock) + )(didResolverService, genericUriResolver)(clock) .mapError(error => PresentationError.UnexpectedError(error.mkString)) } yield result + presentationValidationResult + case any => ZIO.fail(NotImplemented) } - _ <- ZIO.log(s"CredentialsValidationResult: $credentialsValidationResult") + _ <- credentialsValidationResult match + case l @ Failure(_, _) => ZIO.logError(s"CredentialsValidationResult: $l") + case l @ Success(_, _) => ZIO.logInfo(s"CredentialsValidationResult: $l") service <- ZIO.service[PresentationService] presReceivedToProcessedAspect = CustomMetricsAspect.endRecordingTime( s"${record.id}_present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge", diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/StatusListJobs.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/StatusListJobs.scala index 473fd9c331..fdb4adf7df 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/StatusListJobs.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/StatusListJobs.scala @@ -4,10 +4,8 @@ import io.iohk.atala.agent.server.config.AppConfig import io.iohk.atala.agent.walletapi.service.ManagedDIDService import io.iohk.atala.castor.core.model.did.VerificationRelationship import io.iohk.atala.castor.core.service.DIDService -import io.iohk.atala.credentialstatus.controller.http.StatusListCredential -import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError import io.iohk.atala.pollux.core.service.CredentialStatusListService -import io.iohk.atala.pollux.vc.jwt.revocation.{BitString, VCStatusList2021, VCStatusList2021Error} +import io.iohk.atala.pollux.vc.jwt.revocation.{VCStatusList2021, VCStatusList2021Error} import io.iohk.atala.shared.models.WalletAccessContext import zio.* @@ -33,7 +31,6 @@ object StatusListJobs extends BackgroundJobsHelper { .decodeFromJson(vcStatusListCredJson, issuer) .mapError(x => new Throwable(x.msg)) bitString <- vcStatusListCred.getBitString.mapError(x => new Throwable(x.msg)) - encodedBeforeTmp <- bitString.encoded.mapError(x => new Throwable(x.message)) updateBitStringEffects = statusListWithCreds.credentials.map { cred => if cred.isCanceled then bitString.setRevokedInPlace(cred.statusListIndex, true) else ZIO.unit diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/http/StatusListCredential.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/http/StatusListCredential.scala index 43afbc0340..18cbdc78a8 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/http/StatusListCredential.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/http/StatusListCredential.scala @@ -23,6 +23,9 @@ case class StatusListCredential( @description(annotations.issuer.description) @encodedExample(annotations.issuer.example) issuer: String, + @description(annotations.id.description) + @encodedExample(annotations.id.example) + id: String, @description(annotations.issuanceDate.description) @encodedExample(annotations.issuanceDate.example) issuanceDate: Instant, @@ -80,6 +83,12 @@ object StatusListCredential { example = "did:prism:462c4811bf61d7de25b3baf86c5d2f0609b4debe53792d297bf612269bf8593a" ) + object id + extends Annotation[String]( + description = "Unique identifier of status list credential", + example = "http://issuer-agent.com/credential-status/060a2bec-6d6f-4c1f-9414-d3c9dbd3ccc9" + ) + object issuanceDate extends Annotation[Instant]( description = "Issuance timestamp of status list credential", diff --git a/shared/src/main/scala/io/iohk/atala/shared/http/GenericUriResolver.scala b/shared/src/main/scala/io/iohk/atala/shared/http/GenericUriResolver.scala new file mode 100644 index 0000000000..737d17b35b --- /dev/null +++ b/shared/src/main/scala/io/iohk/atala/shared/http/GenericUriResolver.scala @@ -0,0 +1,52 @@ +package io.iohk.atala.shared.http + +import zio.* +import io.lemonlabs.uri.{DataUrl, Uri, Url, Urn} + +trait UriResolver { + + def resolve(uri: String): IO[GenericUriResolverError, String] + +} + +class GenericUriResolver(resolvers: Map[String, UriResolver]) extends UriResolver { + + override def resolve(uri: String): IO[GenericUriResolverError, String] = { + val parsedUri = Uri.parse(uri) + parsedUri match + case url: Url => + url.schemeOption.fold(ZIO.fail(InvalidUri(uri)))(schema => + resolvers.get(schema).fold(ZIO.fail(UnsupportedUriSchema(schema)))(resolver => resolver.resolve(uri)) + ) + + case Urn(path) => ZIO.fail(InvalidUri(uri)) // Must be a URL + } + +} + +class DataUrlResolver extends UriResolver { + override def resolve(dataUrl: String): IO[GenericUriResolverError, String] = { + + DataUrl.parseOption(dataUrl).fold(ZIO.fail(InvalidUri(dataUrl))) { url => + ZIO.succeed(String(url.data, url.mediaType.charset)) + } + + } + +} + +sealed trait GenericUriResolverError { + def toThrowable: Throwable = { + this match + case InvalidUri(uri) => new RuntimeException(s"Invalid URI: $uri") + case UnsupportedUriSchema(schema) => new RuntimeException(s"Unsupported URI schema: $schema") + case SchemaSpecificResolutionError(schema, error) => + new RuntimeException(s"Error resolving ${schema} URL: ${error.getMessage}") + } +} + +case class InvalidUri(uri: String) extends GenericUriResolverError + +case class UnsupportedUriSchema(schema: String) extends GenericUriResolverError + +case class SchemaSpecificResolutionError(schema: String, error: Throwable) extends GenericUriResolverError diff --git a/tests/performance-tests/atala-performance-tests-k6/.npmrc b/tests/performance-tests/atala-performance-tests-k6/.npmrc new file mode 100644 index 0000000000..f48e86cbd8 --- /dev/null +++ b/tests/performance-tests/atala-performance-tests-k6/.npmrc @@ -0,0 +1 @@ +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/tests/performance-tests/atala-performance-tests-k6/.yarnrc b/tests/performance-tests/atala-performance-tests-k6/.yarnrc new file mode 100644 index 0000000000..afeeb1bf85 --- /dev/null +++ b/tests/performance-tests/atala-performance-tests-k6/.yarnrc @@ -0,0 +1 @@ +registry "https://npm.pkg.github.com" diff --git a/tests/performance-tests/atala-performance-tests-k6/README.md b/tests/performance-tests/atala-performance-tests-k6/README.md index 97d69dc5d8..1b3c2226c1 100644 --- a/tests/performance-tests/atala-performance-tests-k6/README.md +++ b/tests/performance-tests/atala-performance-tests-k6/README.md @@ -10,6 +10,9 @@ Clone the generated repository on your local machine, move to the project root folder and install the dependencies defined in [`package.json`](./package.json) +*NOTE*: The Project has a dependency on `input-output-hk/prism-typescript-client` which is a private repository. +To install this dependency, you need to have an environment variable `GITHUB_TOKEN` with the scope `read:packages` set, you can install the dependency by running the following command: + ```bash $ yarn install ``` diff --git a/tests/performance-tests/atala-performance-tests-k6/package.json b/tests/performance-tests/atala-performance-tests-k6/package.json index c7d543ed60..66b789fea2 100644 --- a/tests/performance-tests/atala-performance-tests-k6/package.json +++ b/tests/performance-tests/atala-performance-tests-k6/package.json @@ -23,7 +23,7 @@ "ts-deepmerge": "6.2.0" }, "scripts": { - "start": "webpack" + "webpack": "webpack" }, "dependencies": { "@input-output-hk/prism-typescript-client": "^1.12.0",