diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fced55d16d..86e99de1e0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,9 +6,9 @@ Link to any discussion, related issues and bug reports to give the context to he Link to existing ADR (Architecture Decision Record), if any. If relevant, describe other approaches explored and the selected approach. Documenting why the methods were not selected will create a knowledge base for future reference, helping prevent others from revisiting less optimal ideas. ### Checklist: -- [] My PR follows the [contribution guidelines](https://github.com/hyperledger-labs/open-enterprise-agent/blob/main/CONTRIBUTING.md) of this project -- [] My PR is free of third-party dependencies that don't comply with the [Allowlist](https://toc.hyperledger.org/governing-documents/allowed-third-party-license-policy.html#approved-licenses-for-allowlist) -- [] I have commented my code, particularly in hard-to-understand areas -- [] I have made corresponding changes to the documentation -- [] I have added tests that prove my fix is effective or that my feature works -- [] I have checked the PR title to follow the [conventional commit specification](https://www.conventionalcommits.org/en/v1.0.0/) +- [ ] My PR follows the [contribution guidelines](https://github.com/hyperledger-labs/open-enterprise-agent/blob/main/CONTRIBUTING.md) of this project +- [ ] My PR is free of third-party dependencies that don't comply with the [Allowlist](https://toc.hyperledger.org/governing-documents/allowed-third-party-license-policy.html#approved-licenses-for-allowlist) +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] I have checked the PR title to follow the [conventional commit specification](https://www.conventionalcommits.org/en/v1.0.0/) diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml index 88ce05e44e..f178f39b7a 100644 --- a/.github/workflows/performance-tests.yml +++ b/.github/workflows/performance-tests.yml @@ -13,7 +13,9 @@ on: env: BENCHMARKING_DIR: "tests/performance-tests/atala-performance-tests-k6" - NODE_AUTH_TOKEN: ${{ secrets.ATALA_GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: run-e2e-tests: @@ -35,19 +37,17 @@ jobs: uses: docker/login-action@v2 with: registry: ghcr.io - username: ${{ secrets.ATALA_GITHUB_ACTOR }} - password: ${{ secrets.ATALA_GITHUB_TOKEN }} + username: ${{ env.GITHUB_ACTOR }} + password: ${{ env.GITHUB_TOKEN }} - uses: KengoTODA/actions-setup-docker-compose@v1 name: Install `docker-compose` with: - version: '2.14.2' + version: "2.14.2" - name: Build local version of PRISM Agent env: ENV_FILE: "infrastructure/local/.env" - GITHUB_ACTOR: ${{ secrets.ATALA_GITHUB_ACTOR }} - GITHUB_TOKEN: ${{ secrets.ATALA_GITHUB_TOKEN }} run: | sbt docker:publishLocal PRISM_AGENT_VERSION=$(cut version.sbt -d '=' -f2 | tr -d '" ') @@ -113,7 +113,7 @@ jobs: with: node-version: 16.x registry-url: "https://npm.pkg.github.com" - scope: 'input-output-hk' + scope: "input-output-hk" - name: Install dependencies uses: borales/actions-yarn@v4.2.0 diff --git a/build.sbt b/build.sbt index ed58186f11..18d62d0cc6 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 @@ -117,6 +118,8 @@ lazy val D = new { val circeParser: ModuleID = "io.circe" %% "circe-parser" % V.circe val jwtCirce = "com.github.jwt-scala" %% "jwt-circe" % V.jwtCirceVersion + val jsonCanonicalization: ModuleID = "io.github.erdtman" % "java-json-canonicalization" % "1.1" + val scodecBits: ModuleID = "org.scodec" %% "scodec-bits" % "1.1.38" // https://mvnrepository.com/artifact/org.didcommx/didcomm/0.3.2 val didcommx: ModuleID = "org.didcommx" % "didcomm" % "0.3.1" @@ -153,7 +156,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 @@ -167,10 +170,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.zioCatsInterop, + D.jsonCanonicalization, + D.scodecBits, + D.circeCore, + D.circeGeneric, + D.circeParser, ) } @@ -207,8 +217,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 @@ -225,11 +233,7 @@ lazy val D_Castor = new { D.zioMock, D.zioTestSbt, D.zioTestMagnolia, - D.circeCore, - D.circeGeneric, - D.circeParser, prismIdentity, - scalaUri ) // Project Dependencies @@ -313,9 +317,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 @@ -409,7 +412,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 @@ -582,6 +590,14 @@ lazy val protocolIssueCredential = project .settings(libraryDependencies += D.munitZio) .dependsOn(models) +lazy val protocolRevocationNotification = project + .in(file("mercury/mercury-library/protocol-revocation-notification")) + .settings(name := "mercury-protocol-revocation-notification") + .settings(libraryDependencies += D.zio) + .settings(libraryDependencies ++= Seq(D.circeCore, D.circeGeneric, D.circeParser)) + .settings(libraryDependencies += D.munitZio) + .dependsOn(models) + lazy val protocolPresentProof = project .in(file("mercury/mercury-library/protocol-present-proof")) .settings(name := "mercury-protocol-present-proof") @@ -640,6 +656,7 @@ lazy val agent = project // maybe merge into models protocolMercuryMailbox, protocolLogin, protocolIssueCredential, + protocolRevocationNotification, protocolPresentProof, vc, protocolConnection, @@ -872,6 +889,7 @@ lazy val aggregatedProjects: Seq[ProjectReference] = Seq( protocolReportProblem, protocolRouting, protocolIssueCredential, + protocolRevocationNotification, protocolPresentProof, vc, protocolTrustPing, diff --git a/infrastructure/shared/docker-compose.yml b/infrastructure/shared/docker-compose.yml index bb44b8577a..01bcd0dd58 100644 --- a/infrastructure/shared/docker-compose.yml +++ b/infrastructure/shared/docker-compose.yml @@ -88,6 +88,7 @@ services: AGENT_DB_NAME: agent AGENT_DB_USER: postgres AGENT_DB_PASSWORD: postgres + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: http://${DOCKERHOST}:${PORT}/prism-agent DIDCOMM_SERVICE_URL: http://${DOCKERHOST}:${PORT}/didcomm REST_SERVICE_URL: http://${DOCKERHOST}:${PORT}/prism-agent PRISM_NODE_HOST: prism-node diff --git a/mercury/mercury-library/protocol-revocation-notification/Revocation-notification-protocol.md b/mercury/mercury-library/protocol-revocation-notification/Revocation-notification-protocol.md new file mode 100644 index 0000000000..9aaa147647 --- /dev/null +++ b/mercury/mercury-library/protocol-revocation-notification/Revocation-notification-protocol.md @@ -0,0 +1,33 @@ +# Revocation notification protocol + +This Protocol for an Isuser to notify the revocation of a credential to the holder. + + + +## PIURI + +Version 1.0: + +### Roles + +- Issuer + - Will create the message and send it to the holder via previously established connection +- Holder + - Will process the message as they see fit, protocol does not require any actions from the holder + + +### Revocation notification DIDcomV2 message as JSON + +```json + +{ + "from": "fromDID_value", + "to": "toDID_value", + "piuri":"https://atalaprism.io/revocation_notification/1.0/revoke", + "body": { + "issueCredentialProtocolThreadId": "issueCredentialProtocolThreadId_value", + "comment": "Thread Id used to issue this credential withing issue credential protocol" + } +} + +``` diff --git a/mercury/mercury-library/protocol-revocation-notification/src/main/scala/io/iohk/atala/mercury/protocol/revocationnotificaiton/RevocationNotification.scala b/mercury/mercury-library/protocol-revocation-notification/src/main/scala/io/iohk/atala/mercury/protocol/revocationnotificaiton/RevocationNotification.scala new file mode 100644 index 0000000000..9deed3edea --- /dev/null +++ b/mercury/mercury-library/protocol-revocation-notification/src/main/scala/io/iohk/atala/mercury/protocol/revocationnotificaiton/RevocationNotification.scala @@ -0,0 +1,77 @@ +package io.iohk.atala.mercury.protocol.revocationnotificaiton + +import io.circe._ +import io.circe.generic.semiauto._ +import io.circe.syntax._ + +import io.iohk.atala.mercury.model._ + +final case class RevocationNotification( + id: String = java.util.UUID.randomUUID.toString(), + `type`: PIURI = RevocationNotification.`type`, + body: RevocationNotification.Body, + thid: Option[String] = None, + from: DidId, + to: DidId, +) { + assert(`type` == RevocationNotification.`type`) + + def makeMessage: Message = Message( + id = this.id, + `type` = this.`type`, + from = Some(this.from), + to = Seq(this.to), + thid = this.thid, + body = this.body.asJson.asObject.get, + ) +} +object RevocationNotification { + + given Encoder[RevocationNotification] = deriveEncoder[RevocationNotification] + given Decoder[RevocationNotification] = deriveDecoder[RevocationNotification] + + def `type`: PIURI = "https://atalaprism.io/revocation_notification/1.0/revoke" + + def build( + fromDID: DidId, + toDID: DidId, + thid: Option[String] = None, + issueCredentialProtocolThreadId: String + ): RevocationNotification = { + RevocationNotification( + thid = thid, + from = fromDID, + to = toDID, + body = Body( + issueCredentialProtocolThreadId = issueCredentialProtocolThreadId, + comment = Some("Thread Id used to issue this credential withing issue credential protocol") + ), + ) + } + + final case class Body( + issueCredentialProtocolThreadId: String, + comment: Option[String] = None, + ) + + object Body { + given Encoder[Body] = deriveEncoder[Body] + given Decoder[Body] = deriveDecoder[Body] + } + + def readFromMessage(message: Message): RevocationNotification = + val body = message.body.asJson.as[RevocationNotification.Body].toOption.get + + RevocationNotification( + id = message.id, + `type` = message.piuri, + body = body, + thid = message.thid, + from = message.from.get, // TODO get + to = { + assert(message.to.length == 1, "The recipient is ambiguous. Need to have only 1 recipient") + message.to.head + }, + ) + +} diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/CredentialStatusList.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/CredentialStatusList.scala new file mode 100644 index 0000000000..c59e02533e --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/CredentialStatusList.scala @@ -0,0 +1,56 @@ +package io.iohk.atala.pollux.core.model + +import io.iohk.atala.castor.core.model.did.CanonicalPrismDID +import io.iohk.atala.pollux.vc.jwt.StatusPurpose +import io.iohk.atala.shared.models.WalletId +import java.time.Instant +import java.util.UUID + +final case class CredentialStatusList( + id: UUID, + walletId: WalletId, + issuer: CanonicalPrismDID, + issued: Instant, + purpose: StatusPurpose, + statusListCredential: String, + size: Int, + lastUsedIndex: Int, + createdAt: Instant, + updatedAt: Option[Instant] +) + +case class CredInStatusList( + id: UUID, + issueCredentialRecordId: DidCommID, + statusListIndex: Int, + isCanceled: Boolean, + isProcessed: Boolean, +) + +case class CredentialStatusListWithCred( + credentialStatusListId: UUID, + issuer: CanonicalPrismDID, + issued: Instant, + purpose: StatusPurpose, + walletId: WalletId, + statusListCredential: String, + size: Int, + lastUsedIndex: Int, + credentialInStatusListId: UUID, + issueCredentialRecordId: DidCommID, + statusListIndex: Int, + isCanceled: Boolean, + isProcessed: Boolean, +) + +case class CredentialStatusListWithCreds( + id: UUID, + walletId: WalletId, + issuer: CanonicalPrismDID, + issued: Instant, + purpose: StatusPurpose, + statusListCredential: String, + size: Int, + lastUsedIndex: Int, + credentials: Seq[CredInStatusList] +) diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialServiceError.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialServiceError.scala index 140013be7c..0087db326b 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialServiceError.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialServiceError.scala @@ -5,7 +5,34 @@ import io.iohk.atala.pollux.vc.jwt.W3cCredentialPayload import java.util.UUID -sealed trait CredentialServiceError +sealed trait CredentialServiceError { + def toThrowable: Throwable = this match + case CredentialServiceError.RepositoryError(cause) => cause + case CredentialServiceError.LinkSecretError(cause) => cause + case CredentialServiceError.RecordIdNotFound(recordId) => new Throwable(s"RecordId not found: $recordId") + case CredentialServiceError.OperationNotExecuted(recordId, info) => + new Throwable(s"Operation not executed for recordId: $recordId, info: $info") + case CredentialServiceError.ThreadIdNotFound(thid) => new Throwable(s"ThreadId not found: $thid") + case CredentialServiceError.InvalidFlowStateError(msg) => new Throwable(s"Invalid flow state: $msg") + case CredentialServiceError.UnexpectedError(msg) => new Throwable(s"Unexpected error: $msg") + case CredentialServiceError.UnsupportedDidFormat(did) => new Throwable(s"Unsupported DID format: $did") + case CredentialServiceError.UnsupportedCredentialFormat(vcFormat) => + new Throwable(s"Unsupported credential format: $vcFormat") + case CredentialServiceError.MissingCredentialFormat => new Throwable("Missing credential format") + case CredentialServiceError.CreateCredentialPayloadFromRecordError(cause) => cause + case CredentialServiceError.CredentialRequestValidationError(error) => + new Throwable(s"Credential request validation error: $error") + case CredentialServiceError.CredentialIdNotDefined(credential) => + new Throwable(s"CredentialId not defined for credential: $credential") + case CredentialServiceError.CredentialSchemaError(cause) => + new Throwable(s"Credential schema error: ${cause.message}") + case CredentialServiceError.UnsupportedVCClaimsValue(error) => new Throwable(s"Unsupported VC claims value: $error") + case CredentialServiceError.UnsupportedVCClaimsMediaType(media_type) => + new Throwable(s"Unsupported VC claims media type: $media_type") + case CredentialServiceError.CredentialDefinitionPrivatePartNotFound(credentialDefinitionId) => + new Throwable(s"Credential definition private part not found: $credentialDefinitionId") + case CredentialServiceError.CredentialDefinitionIdUndefined => new Throwable("Credential definition id undefined") +} object CredentialServiceError { final case class RepositoryError(cause: Throwable) extends CredentialServiceError diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialStatusListServiceError.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialStatusListServiceError.scala new file mode 100644 index 0000000000..21354d0040 --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/model/error/CredentialStatusListServiceError.scala @@ -0,0 +1,24 @@ +package io.iohk.atala.pollux.core.model.error + +import io.iohk.atala.pollux.core.model.DidCommID +import io.iohk.atala.pollux.vc.jwt.W3cCredentialPayload + +import java.util.UUID + +sealed trait CredentialStatusListServiceError { + def toThrowable: Throwable = this match + case CredentialStatusListServiceError.RepositoryError(cause) => cause + case CredentialStatusListServiceError.RecordIdNotFound(id) => + new Exception(s"Credential status list with id: $id not found") + case CredentialStatusListServiceError.IssueCredentialRecordNotFound(id) => + new Exception(s"Issue credential record with id: $id not found") + case CredentialStatusListServiceError.JsonCredentialParsingError(cause) => cause + +} + +object CredentialStatusListServiceError { + final case class RepositoryError(cause: Throwable) extends CredentialStatusListServiceError + final case class RecordIdNotFound(id: UUID) extends CredentialStatusListServiceError + final case class IssueCredentialRecordNotFound(id: DidCommID) extends CredentialStatusListServiceError + final case class JsonCredentialParsingError(cause: Throwable) extends CredentialStatusListServiceError +} diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepository.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepository.scala new file mode 100644 index 0000000000..978bcd87fb --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepository.scala @@ -0,0 +1,43 @@ +package io.iohk.atala.pollux.core.repository + +import io.iohk.atala.castor.core.model.did.CanonicalPrismDID +import io.iohk.atala.mercury.protocol.issuecredential.{IssueCredential, RequestCredential} +import io.iohk.atala.pollux.core.model.* +import io.iohk.atala.pollux.core.model.IssueCredentialRecord.ProtocolState +import io.iohk.atala.pollux.vc.jwt.Issuer +import io.iohk.atala.shared.models.{WalletAccessContext, WalletId} +import zio.* + +import java.util.UUID + +trait CredentialStatusListRepository { + def getLatestOfTheWallet: RIO[WalletAccessContext, Option[CredentialStatusList]] + + def findById(id: UUID): Task[Option[CredentialStatusList]] + + def createNewForTheWallet( + jwtIssuer: Issuer, + statusListRegistryUrl: String + ): RIO[WalletAccessContext, CredentialStatusList] + + def allocateSpaceForCredential( + issueCredentialRecordId: DidCommID, + credentialStatusListId: UUID, + statusListIndex: Int + ): RIO[WalletAccessContext, Unit] + + def revokeByIssueCredentialRecordId( + issueCredentialRecordId: DidCommID + ): RIO[WalletAccessContext, Boolean] + + def getCredentialStatusListsWithCreds: Task[List[CredentialStatusListWithCreds]] + + def updateStatusListCredential( + credentialStatusListId: UUID, + statusListCredential: String + ): RIO[WalletAccessContext, Unit] + + def markAsProcessedMany( + credsInStatusListIds: Seq[UUID] + ): RIO[WalletAccessContext, Unit] +} diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala new file mode 100644 index 0000000000..cb4521dddb --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/repository/CredentialStatusListRepositoryInMemory.scala @@ -0,0 +1,277 @@ +package io.iohk.atala.pollux.core.repository + +import io.iohk.atala.pollux.core.model.CredentialStatusList +import io.iohk.atala.pollux.vc.jwt.{Issuer, StatusPurpose, revocation} +import io.iohk.atala.shared.models.{WalletAccessContext, WalletId} +import zio.* +import io.iohk.atala.pollux.core.model.* +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.{ + DecodingError, + EncodingError, + IndexOutOfBounds, + InvalidSize +} +import io.iohk.atala.castor.core.model.did.{CanonicalPrismDID, PrismDID} +import io.iohk.atala.pollux.vc.jwt.revocation.{BitString, VCStatusList2021} + +import java.time.Instant +import java.util.UUID + +class CredentialStatusListRepositoryInMemory( + walletToStatusListRefs: Ref[Map[WalletId, Ref[Map[UUID, CredentialStatusList]]]], + statusListToCredInStatusListRefs: Ref[Map[UUID, Ref[Map[UUID, CredentialInStatusList]]]] +) extends CredentialStatusListRepository { + + private def walletToStatusListStorageRefs: URIO[WalletAccessContext, Ref[Map[UUID, CredentialStatusList]]] = + for { + walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) + refs <- walletToStatusListRefs.get + maybeWalletRef = refs.get(walletId) + walletRef <- maybeWalletRef + .fold { + for { + ref <- Ref.make(Map.empty[UUID, CredentialStatusList]) + _ <- walletToStatusListRefs.set(refs.updated(walletId, ref)) + } yield ref + }(ZIO.succeed) + } yield walletRef + + private def allStatusListsStorageRefs: Task[Ref[Map[UUID, CredentialStatusList]]] = + for { + refs <- walletToStatusListRefs.get + allRefs = refs.values.toList + allRefsMap <- ZIO + .collectAll(allRefs.map(_.get)) + .map(_.foldLeft(Map.empty[UUID, CredentialStatusList]) { (acc, value) => + acc ++ value + }) + ref <- Ref.make(allRefsMap) + } yield ref + + private def statusListToCredInStatusListStorageRefs( + statusListId: UUID + ): Task[Ref[Map[UUID, CredentialInStatusList]]] = + for { + refs <- statusListToCredInStatusListRefs.get + maybeStatusListIdRef = refs.get(statusListId) + statusListIdRef <- maybeStatusListIdRef.fold { + for { + ref <- Ref.make(Map.empty[UUID, CredentialInStatusList]) + _ <- statusListToCredInStatusListRefs.set(refs.updated(statusListId, ref)) + } yield ref + }(ZIO.succeed) + } yield statusListIdRef + + def findById(id: UUID): Task[Option[CredentialStatusList]] = for { + refs <- walletToStatusListRefs.get + stores <- ZIO.foreach(refs.values.toList)(_.get) + found = stores.flatMap(_.values).find(_.id == id) + } yield found + + def getLatestOfTheWallet: RIO[WalletAccessContext, Option[CredentialStatusList]] = for { + storageRef <- walletToStatusListStorageRefs + storage <- storageRef.get + latest = storage.toSeq + .sortBy(_._2.createdAt) { (x, y) => if x.isAfter(y) then -1 else 1 /* DESC */ } + .headOption + .map(_._2) + } yield latest + + def createNewForTheWallet( + jwtIssuer: Issuer, + statusListRegistryUrl: String + ): RIO[WalletAccessContext, CredentialStatusList] = { + + val id = UUID.randomUUID() + val issued = Instant.now() + val issuerDid = jwtIssuer.did.value + val canonical = PrismDID.fromString(issuerDid).fold(e => throw RuntimeException(e), _.asCanonical) + + val embeddedProofCredential = for { + bitString <- BitString.getInstance().mapError { + case InvalidSize(message) => new Throwable(message) + case EncodingError(message) => new Throwable(message) + case DecodingError(message) => new Throwable(message) + case IndexOutOfBounds(message) => new Throwable(message) + } + emptyJwtCredential <- VCStatusList2021 + .build( + vcId = s"$statusListRegistryUrl/credential-status/$id", + slId = "", + revocationData = bitString, + jwtIssuer = jwtIssuer + ) + .mapError(x => new Throwable(x.msg)) + + credentialWithEmbeddedProof <- emptyJwtCredential.toJsonWithEmbeddedProof + } yield credentialWithEmbeddedProof.spaces2 + + for { + credential <- embeddedProofCredential + storageRef <- walletToStatusListStorageRefs + walletId <- ZIO.serviceWith[WalletAccessContext](_.walletId) + newCredentialStatusList = CredentialStatusList( + id = id, + walletId = walletId, + issuer = canonical, + issued = issued, + purpose = StatusPurpose.Revocation, + statusListCredential = credential, + size = BitString.MIN_SL2021_SIZE, + lastUsedIndex = 0, + createdAt = Instant.now(), + updatedAt = None + ) + _ <- storageRef.update(r => r + (newCredentialStatusList.id -> newCredentialStatusList)) + } yield newCredentialStatusList + + } + + def allocateSpaceForCredential( + issueCredentialRecordId: DidCommID, + credentialStatusListId: UUID, + statusListIndex: Int + ): RIO[WalletAccessContext, Unit] = { + val newCredentialInStatusList = CredentialInStatusList( + id = UUID.randomUUID(), + issueCredentialRecordId = issueCredentialRecordId, + credentialStatusListId = credentialStatusListId, + statusListIndex = statusListIndex, + isCanceled = false, + isProcessed = false, + createdAt = Instant.now(), + updatedAt = None + ) + + for { + credentialInStatusListStorageRef <- statusListToCredInStatusListStorageRefs(credentialStatusListId) + _ <- credentialInStatusListStorageRef.update(r => r + (newCredentialInStatusList.id -> newCredentialInStatusList)) + walletToStatusListStorageRef <- walletToStatusListStorageRefs + _ <- walletToStatusListStorageRef.update(r => { + val value = r.get(credentialStatusListId) + value.fold(r) { v => + val updated = v.copy(lastUsedIndex = statusListIndex, updatedAt = Some(Instant.now)) + r.updated(credentialStatusListId, updated) + } + }) + } yield () + + } + + def revokeByIssueCredentialRecordId( + issueCredentialRecordId: DidCommID + ): RIO[WalletAccessContext, Boolean] = { + var isUpdated = false + for { + statusListsRefs <- walletToStatusListStorageRefs + statusLists <- statusListsRefs.get + credInStatusListsRefs <- ZIO + .collectAll(statusLists.keys.map(k => statusListToCredInStatusListStorageRefs(k))) + .map(_.toVector) + _ = credInStatusListsRefs.foreach(ref => + ref.update { credInStatusListsMap => + val maybeFound = credInStatusListsMap.find(_._2.issueCredentialRecordId == issueCredentialRecordId) + maybeFound.fold(credInStatusListsMap) { case (id, value) => + if (!value.isCanceled) { + credInStatusListsMap.updated(id, value.copy(isCanceled = true, updatedAt = Some(Instant.now()))) + isUpdated = true + credInStatusListsMap + } else credInStatusListsMap + } + + } + ) + } yield isUpdated + } + + def getCredentialStatusListsWithCreds: Task[List[CredentialStatusListWithCreds]] = { + for { + statusListsRefs <- allStatusListsStorageRefs + statusLists <- statusListsRefs.get + statusListWithCredEffects = statusLists.map { (id, statusList) => + val credsinStatusListEffect = statusListToCredInStatusListStorageRefs(id).flatMap(_.get.map(_.values.toList)) + credsinStatusListEffect.map { credsInStatusList => + CredentialStatusListWithCreds( + id = id, + walletId = statusList.walletId, + issuer = statusList.issuer, + issued = statusList.issued, + purpose = statusList.purpose, + statusListCredential = statusList.statusListCredential, + size = statusList.size, + lastUsedIndex = statusList.lastUsedIndex, + credentials = credsInStatusList.map { cred => + CredInStatusList( + id = cred.id, + issueCredentialRecordId = cred.issueCredentialRecordId, + statusListIndex = cred.statusListIndex, + isCanceled = cred.isCanceled, + isProcessed = cred.isProcessed, + ) + } + ) + } + + }.toList + res <- ZIO.collectAll(statusListWithCredEffects) + } yield res + } + + def updateStatusListCredential( + credentialStatusListId: UUID, + statusListCredential: String + ): RIO[WalletAccessContext, Unit] = { + for { + statusListsRefs <- walletToStatusListStorageRefs + _ <- statusListsRefs.update { statusLists => + statusLists.updatedWith(credentialStatusListId) { maybeCredentialStatusList => + maybeCredentialStatusList.map { credentialStatusList => + credentialStatusList.copy(statusListCredential = statusListCredential, updatedAt = Some(Instant.now())) + } + } + } + } yield () + + } + + def markAsProcessedMany( + credsInStatusListIds: Seq[UUID] + ): RIO[WalletAccessContext, Unit] = for { + statusListsRefs <- walletToStatusListStorageRefs + statusLists <- statusListsRefs.get + credInStatusListsRefs <- ZIO + .collectAll(statusLists.keys.map(k => statusListToCredInStatusListStorageRefs(k))) + .map(_.toVector) + _ = credInStatusListsRefs.foreach(ref => + ref.update { credInStatusListsMap => + credInStatusListsMap.transform { (id, credInStatusList) => + if (credsInStatusListIds.contains(id)) + credInStatusList.copy(isProcessed = true, updatedAt = Some(Instant.now())) + else credInStatusList + } + } + ) + } yield () + +} + +object CredentialStatusListRepositoryInMemory { + val layer: ULayer[CredentialStatusListRepositoryInMemory] = ZLayer.fromZIO( + for { + walletToStatusList <- Ref + .make(Map.empty[WalletId, Ref[Map[UUID, CredentialStatusList]]]) + statusListIdToCredInStatusList <- Ref.make(Map.empty[UUID, Ref[Map[UUID, CredentialInStatusList]]]) + } yield CredentialStatusListRepositoryInMemory(walletToStatusList, statusListIdToCredInStatusList) + ) +} + +private case class CredentialInStatusList( + id: UUID, + issueCredentialRecordId: DidCommID, + credentialStatusListId: UUID, + statusListIndex: Int, + isCanceled: Boolean, + isProcessed: Boolean, + createdAt: Instant = Instant.now(), + updatedAt: Option[Instant] = None +) diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialSchemaServiceImpl.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialSchemaServiceImpl.scala index 98ebe54654..29b943f601 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialSchemaServiceImpl.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialSchemaServiceImpl.scala @@ -1,4 +1,5 @@ package io.iohk.atala.pollux.core.service + import io.iohk.atala.pollux.core.model.error.CredentialSchemaError import io.iohk.atala.pollux.core.model.schema.CredentialSchema import io.iohk.atala.pollux.core.model.schema.CredentialSchema.FilteredEntries diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialService.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialService.scala index 47622c4335..85e453ce08 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialService.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialService.scala @@ -93,6 +93,7 @@ trait CredentialService { def generateJWTCredential( recordId: DidCommID, + statusListRegistryUrl: String, ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] def generateAnonCredsCredential( 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 0d4250c9a2..0e98ac6d93 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 @@ -24,8 +24,9 @@ import io.iohk.atala.pollux.core.model.error.CredentialServiceError.* import io.iohk.atala.pollux.core.model.presentation.* import io.iohk.atala.pollux.core.model.schema.CredentialSchema import io.iohk.atala.pollux.core.model.secret.CredentialDefinitionSecret -import io.iohk.atala.pollux.core.repository.CredentialRepository +import io.iohk.atala.pollux.core.repository.{CredentialRepository, CredentialStatusListRepository} import io.iohk.atala.pollux.vc.jwt.{ES256KSigner, Issuer as JwtIssuer, *} +import io.iohk.atala.shared.http.{DataUrlResolver, GenericUriResolver} import io.iohk.atala.shared.models.WalletAccessContext import io.iohk.atala.shared.utils.aspects.CustomMetricsAspect import zio.* @@ -39,18 +40,45 @@ import scala.language.implicitConversions object CredentialServiceImpl { val layer: URLayer[ - CredentialRepository & DidResolver & URIDereferencer & GenericSecretStorage & CredentialDefinitionService & - LinkSecretService & DIDService & ManagedDIDService, + CredentialRepository & CredentialStatusListRepository & DidResolver & URIDereferencer & GenericSecretStorage & + CredentialDefinitionService & LinkSecretService & DIDService & ManagedDIDService, CredentialService - ] = - ZLayer.fromFunction(CredentialServiceImpl(_, _, _, _, _, _, _, _)) + ] = { + ZLayer.fromZIO { + for { + credentialRepo <- ZIO.service[CredentialRepository] + credentialStatusListRepo <- ZIO.service[CredentialStatusListRepository] + didResolver <- ZIO.service[DidResolver] + uriDereferencer <- ZIO.service[URIDereferencer] + genericSecretStorage <- ZIO.service[GenericSecretStorage] + credDefenitionService <- ZIO.service[CredentialDefinitionService] + linkSecretService <- ZIO.service[LinkSecretService] + didService <- ZIO.service[DIDService] + manageDidService <- ZIO.service[ManagedDIDService] + issueCredentialSem <- Semaphore.make(1) + } yield CredentialServiceImpl( + credentialRepo, + credentialStatusListRepo, + didResolver, + uriDereferencer, + genericSecretStorage, + credDefenitionService, + linkSecretService, + didService, + manageDidService, + 5, + issueCredentialSem + ) + } + } -// private val VC_JSON_SCHEMA_URI = "https://w3c-ccg.github.io/vc-json-schemas/schema/2.0/schema.json" + // private val VC_JSON_SCHEMA_URI = "https://w3c-ccg.github.io/vc-json-schemas/schema/2.0/schema.json" private val VC_JSON_SCHEMA_TYPE = "CredentialSchema2022" } private class CredentialServiceImpl( credentialRepository: CredentialRepository, + credentialStatusListRepository: CredentialStatusListRepository, didResolver: DidResolver, uriDereferencer: URIDereferencer, genericSecretStorage: GenericSecretStorage, @@ -58,7 +86,8 @@ private class CredentialServiceImpl( linkSecretService: LinkSecretService, didService: DIDService, managedDIDService: ManagedDIDService, - maxRetries: Int = 5 // TODO move to config + maxRetries: Int = 5, // TODO move to config + issueCredentialSem: Semaphore ) extends CredentialService { import CredentialServiceImpl.* @@ -265,7 +294,6 @@ private class CredentialServiceImpl( case value => ZIO.fail(UnsupportedCredentialFormat(value)) _ <- validateCredentialOfferAttachment(credentialFormat, attachment) - record <- ZIO.succeed( IssueCredentialRecord( id = DidCommID(), @@ -1007,7 +1035,8 @@ private class CredentialServiceImpl( } override def generateJWTCredential( - recordId: DidCommID + recordId: DidCommID, + statusListRegistryUrl: String, ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { for { record <- getRecordWithState(recordId, ProtocolState.CredentialPending) @@ -1046,6 +1075,7 @@ private class CredentialServiceImpl( payload => ZIO.logInfo("JWT Presentation Validation Successful!") ) issuanceDate = Instant.now() + credentialStatus <- allocateNewCredentialInStatusListForWallet(record, statusListRegistryUrl, jwtIssuer) // TODO: get schema when schema registry is available if schema ID is provided w3Credential = W3cCredentialPayload( `@context` = Set( @@ -1059,8 +1089,8 @@ private class CredentialServiceImpl( maybeExpirationDate = record.validityPeriod.map(sec => issuanceDate.plusSeconds(sec.toLong)), maybeCredentialSchema = record.schemaUri.map(id => io.iohk.atala.pollux.vc.jwt.CredentialSchema(id, VC_JSON_SCHEMA_TYPE)), + maybeCredentialStatus = Some(credentialStatus), credentialSubject = claims.add("id", jwtPresentation.iss.asJson).asJson, - maybeCredentialStatus = None, maybeRefreshService = None, maybeEvidence = None, maybeTermsOfUse = None @@ -1076,6 +1106,47 @@ private class CredentialServiceImpl( } yield record } + private[this] def allocateNewCredentialInStatusListForWallet( + record: IssueCredentialRecord, + statusListRegistryUrl: String, + jwtIssuer: JwtIssuer + ): ZIO[WalletAccessContext, CredentialServiceError, CredentialStatus] = { + for { + lastStatusList <- credentialStatusListRepository.getLatestOfTheWallet.mapError(RepositoryError.apply) + currentStatusList <- lastStatusList + .fold(credentialStatusListRepository.createNewForTheWallet(jwtIssuer, statusListRegistryUrl))( + ZIO.succeed(_) + ) + .mapError(RepositoryError.apply) + size = currentStatusList.size + lastUsedIndex = currentStatusList.lastUsedIndex + statusListToBeUsed <- issueCredentialSem.withPermit { + for { + statusListToBeUsed <- + if lastUsedIndex < size then ZIO.succeed(currentStatusList) + else + credentialStatusListRepository + .createNewForTheWallet(jwtIssuer, statusListRegistryUrl) + .mapError(RepositoryError.apply) + + _ <- credentialStatusListRepository + .allocateSpaceForCredential( + issueCredentialRecordId = record.id, + credentialStatusListId = statusListToBeUsed.id, + statusListIndex = statusListToBeUsed.lastUsedIndex + 1 + ) + .mapError(RepositoryError.apply) + } yield statusListToBeUsed + } + } yield CredentialStatus( + id = s"$statusListRegistryUrl/credential-status/${statusListToBeUsed.id}#${statusListToBeUsed.lastUsedIndex + 1}", + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = lastUsedIndex + 1, + statusListCredential = s"$statusListRegistryUrl/credential-status/${statusListToBeUsed.id}" + ) + } + override def generateAnonCredsCredential( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { @@ -1212,6 +1283,11 @@ private class CredentialServiceImpl( clock = java.time.Clock.system(ZoneId.systemDefault) + genericUriResolver = GenericUriResolver( + Map( + "data" -> DataUrlResolver(), + ) + ) verificationResult <- JwtPresentation .verify( jwt, @@ -1221,7 +1297,7 @@ private class CredentialServiceImpl( verifyDates = false, leeway = Duration.Zero ) - )(didResolver)(clock) + )(didResolver, genericUriResolver)(clock) .mapError(errors => CredentialRequestValidationError(s"JWT presentation verification failed: $errors")) result <- verificationResult match diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifier.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifier.scala index 177a9c7904..504b82b5d1 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifier.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifier.scala @@ -117,9 +117,10 @@ class CredentialServiceNotifier( notifyOnSuccess(svc.receiveCredentialIssue(issueCredential)) override def generateJWTCredential( - recordId: DidCommID + recordId: DidCommID, + statusListRegistryUrl: String ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = - notifyOnSuccess(svc.generateJWTCredential(recordId)) + notifyOnSuccess(svc.generateJWTCredential(recordId, statusListRegistryUrl)) override def generateAnonCredsCredential( recordId: DidCommID diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListService.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListService.scala new file mode 100644 index 0000000000..e3b9930c9a --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListService.scala @@ -0,0 +1,23 @@ +package io.iohk.atala.pollux.core.service + +import io.iohk.atala.pollux.core.model.{CredentialStatusList, CredentialStatusListWithCreds, DidCommID} +import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError +import zio.* +import io.iohk.atala.shared.models.WalletAccessContext + +import java.util.UUID + +trait CredentialStatusListService { + def findById(id: UUID): IO[CredentialStatusListServiceError, CredentialStatusList] + + def revokeByIssueCredentialRecordId(id: DidCommID): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] + + def getCredentialsAndItsStatuses: IO[CredentialStatusListServiceError, Seq[CredentialStatusListWithCreds]] + + def updateStatusListCredential( + id: UUID, + statusListCredential: String + ): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] + + def markAsProcessedMany(ids: Seq[UUID]): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] +} diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListServiceImpl.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListServiceImpl.scala new file mode 100644 index 0000000000..f272a1784d --- /dev/null +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/CredentialStatusListServiceImpl.scala @@ -0,0 +1,61 @@ +package io.iohk.atala.pollux.core.service + +import io.iohk.atala.pollux.core.model.{CredentialStatusList, CredentialStatusListWithCreds, DidCommID} +import io.iohk.atala.pollux.core.repository.CredentialStatusListRepository +import zio.* +import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError +import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError.* +import io.iohk.atala.shared.models.WalletAccessContext + +import java.util.UUID + +class CredentialStatusListServiceImpl( + credentialStatusListRepository: CredentialStatusListRepository, +) extends CredentialStatusListService { + + def findById(id: UUID): IO[CredentialStatusListServiceError, CredentialStatusList] = + for { + maybeStatusList <- credentialStatusListRepository.findById(id).mapError(RepositoryError.apply) + statuslist <- ZIO + .getOrFailWith(RecordIdNotFound(id))( + maybeStatusList + ) + } yield statuslist + + def revokeByIssueCredentialRecordId( + id: DidCommID + ): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] = { + for { + revoked <- credentialStatusListRepository.revokeByIssueCredentialRecordId(id).mapError(RepositoryError.apply) + _ <- if (revoked) ZIO.unit else ZIO.fail(IssueCredentialRecordNotFound(id)) + } yield () + + } + + def getCredentialsAndItsStatuses: IO[CredentialStatusListServiceError, Seq[CredentialStatusListWithCreds]] = { + credentialStatusListRepository.getCredentialStatusListsWithCreds.mapError(RepositoryError.apply) + } + + def updateStatusListCredential( + id: UUID, + statusListCredential: String + ): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] = { + credentialStatusListRepository + .updateStatusListCredential(id, statusListCredential) + .mapError(RepositoryError.apply) + } + + def markAsProcessedMany( + credsInStatusListIds: Seq[UUID] + ): ZIO[WalletAccessContext, CredentialStatusListServiceError, Unit] = { + credentialStatusListRepository + .markAsProcessedMany(credsInStatusListIds) + .mapError(RepositoryError.apply) + } + +} + +object CredentialStatusListServiceImpl { + val layer: URLayer[CredentialStatusListRepository, CredentialStatusListService] = + ZLayer.fromFunction(CredentialStatusListServiceImpl(_)) +} diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/HttpURIDereferencerImpl.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/HttpURIDereferencerImpl.scala index 62561ccf2e..56057c9f7b 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/HttpURIDereferencerImpl.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/HttpURIDereferencerImpl.scala @@ -3,7 +3,6 @@ package io.iohk.atala.pollux.core.service import io.iohk.atala.pollux.core.service.URIDereferencerError.{ConnectionError, ResourceNotFound, UnexpectedError} import zio.* import zio.http.* - import java.net.URI import java.nio.charset.StandardCharsets diff --git a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/MockCredentialService.scala b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/MockCredentialService.scala index f7a2a72f73..21b7d82e73 100644 --- a/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/MockCredentialService.scala +++ b/pollux/lib/core/src/main/scala/io/iohk/atala/pollux/core/service/MockCredentialService.scala @@ -55,7 +55,7 @@ object MockCredentialService extends Mock[CredentialService] { object GenerateAnonCredsCredentialRequest extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] object ReceiveCredentialRequest extends Effect[RequestCredential, CredentialServiceError, IssueCredentialRecord] object AcceptCredentialRequest extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] - object GenerateJWTCredential extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] + object GenerateJWTCredential extends Effect[(DidCommID, String), CredentialServiceError, IssueCredentialRecord] object GenerateAnonCredsCredential extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] object MarkCredentialRecordsAsPublishQueued extends Effect[Seq[(W3cCredentialPayload, MerkleInclusionProof)], CredentialServiceError, Int] @@ -145,9 +145,10 @@ object MockCredentialService extends Mock[CredentialService] { proxy(AcceptCredentialRequest, recordId) override def generateJWTCredential( - recordId: DidCommID + recordId: DidCommID, + statusListRegistryUrl: String, ): IO[CredentialServiceError, IssueCredentialRecord] = - proxy(GenerateJWTCredential, recordId) + proxy(GenerateJWTCredential, recordId, statusListRegistryUrl) override def generateAnonCredsCredential( recordId: DidCommID diff --git a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceImplSpec.scala b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceImplSpec.scala index 5b2c22ac49..94840909fb 100644 --- a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceImplSpec.scala +++ b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceImplSpec.scala @@ -519,7 +519,10 @@ object CredentialServiceImplSpec extends MockSpecDefault with CredentialServiceS // Issuer accepts request requestAcceptedRecord <- issuerSvc.acceptCredentialRequest(issuerRecordId) // Issuer generates credential - credentialGenerateRecord <- issuerSvc.generateJWTCredential(issuerRecordId) + credentialGenerateRecord <- issuerSvc.generateJWTCredential( + issuerRecordId, + "https://test-status-list.registry" + ) // Issuer sends credential _ <- issuerSvc.markCredentialSent(issuerRecordId) msg <- ZIO.fromEither(credentialGenerateRecord.issueCredentialData.get.makeMessage.asJson.as[Message]) diff --git a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifierSpec.scala b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifierSpec.scala index 17e3ce7a3b..4bbba5c2b0 100644 --- a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifierSpec.scala +++ b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceNotifierSpec.scala @@ -98,7 +98,7 @@ object CredentialServiceNotifierSpec extends MockSpecDefault with CredentialServ _ <- svc.markOfferSent(issuerRecordId) _ <- svc.receiveCredentialRequest(requestCredential()) _ <- svc.acceptCredentialRequest(issuerRecordId) - _ <- svc.generateJWTCredential(issuerRecordId) + _ <- svc.generateJWTCredential(issuerRecordId, "https://test-status-list.registry") _ <- svc.markCredentialSent(issuerRecordId) consumer <- ens.consumer[IssueCredentialRecord]("Issue") events <- consumer.poll(50) diff --git a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceSpecHelper.scala b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceSpecHelper.scala index 10b986d98e..51921eb8e0 100644 --- a/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceSpecHelper.scala +++ b/pollux/lib/core/src/test/scala/io/iohk/atala/pollux/core/service/CredentialServiceSpecHelper.scala @@ -10,7 +10,11 @@ import io.iohk.atala.mercury.model.{AttachmentDescriptor, DidId} import io.iohk.atala.mercury.protocol.issuecredential.* import io.iohk.atala.pollux.core.model.* import io.iohk.atala.pollux.core.model.presentation.{ClaimFormat, Ldp, Options, PresentationDefinition} -import io.iohk.atala.pollux.core.repository.{CredentialDefinitionRepositoryInMemory, CredentialRepositoryInMemory} +import io.iohk.atala.pollux.core.repository.{ + CredentialDefinitionRepositoryInMemory, + CredentialRepositoryInMemory, + CredentialStatusListRepositoryInMemory +} import io.iohk.atala.pollux.vc.jwt.* import io.iohk.atala.shared.models.{WalletAccessContext, WalletId} import zio.* @@ -31,6 +35,7 @@ trait CredentialServiceSpecHelper { : URLayer[DIDService & ManagedDIDService & URIDereferencer, CredentialService & CredentialDefinitionService] = ZLayer.makeSome[DIDService & ManagedDIDService & URIDereferencer, CredentialService & CredentialDefinitionService]( CredentialRepositoryInMemory.layer, + CredentialStatusListRepositoryInMemory.layer, ZLayer.fromFunction(PrismDidResolver(_)), credentialDefinitionServiceLayer, GenericSecretStorageInMemory.layer, diff --git a/pollux/lib/sql-doobie/src/main/resources/sql/pollux/V16__revocation_status_lists_table_and_columns.sql b/pollux/lib/sql-doobie/src/main/resources/sql/pollux/V16__revocation_status_lists_table_and_columns.sql index a1c4847351..32ac117c68 100644 --- a/pollux/lib/sql-doobie/src/main/resources/sql/pollux/V16__revocation_status_lists_table_and_columns.sql +++ b/pollux/lib/sql-doobie/src/main/resources/sql/pollux/V16__revocation_status_lists_table_and_columns.sql @@ -5,15 +5,16 @@ CREATE TYPE public.enum_credential_status_list_purpose AS ENUM ( CREATE TABLE public.credential_status_lists ( - id UUID PRIMARY KEY default gen_random_uuid(), - wallet_id UUID NOT NULL, - issuer VARCHAR NOT NULL, - issued TIMESTAMP WITH TIME ZONE NOT NULL, - purpose public.enum_credential_status_list_purpose NOT NULL, - encoded_list TEXT NOT NULL, - proof JSON NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL default now() + id UUID PRIMARY KEY default gen_random_uuid(), + wallet_id UUID NOT NULL, + issuer VARCHAR NOT NULL, + issued TIMESTAMP WITH TIME ZONE NOT NULL, + purpose public.enum_credential_status_list_purpose NOT NULL, + status_list_credential JSON NOT NULL, + size INTEGER NOT NULL DEFAULT 131072, + last_used_index INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL default now() ); CREATE INDEX credential_status_lists_wallet_id_index ON public.credential_status_lists (wallet_id); @@ -27,6 +28,7 @@ CREATE TABLE public.credentials_in_status_list status_list_index INTEGER NOT NULL, -- is revoked or suspended is_canceled BOOLEAN NOT NULL default false, + is_processed BOOLEAN NOT NULL default false, created_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL default now(), diff --git a/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/Implicits.scala b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/Implicits.scala new file mode 100644 index 0000000000..a741410a42 --- /dev/null +++ b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/Implicits.scala @@ -0,0 +1,28 @@ +package io.iohk.atala.pollux.sql.repository + +import doobie.util.{Get, Put} +import io.iohk.atala.castor.core.model.did.{CanonicalPrismDID, PrismDID} +import io.iohk.atala.pollux.core.model.* +import io.iohk.atala.pollux.vc.jwt.StatusPurpose +import io.iohk.atala.shared.models.WalletId + +given didCommIDGet: Get[DidCommID] = Get[String].map(DidCommID(_)) +given didCommIDPut: Put[DidCommID] = Put[String].contramap(_.value) + +given walletIdGet: Get[WalletId] = Get[String].map(WalletId.fromUUIDString) +given walletIdPut: Put[WalletId] = Put[String].contramap(_.toString) + +given prismDIDGet: Get[CanonicalPrismDID] = + Get[String].map(s => PrismDID.fromString(s).fold(e => throw RuntimeException(e), _.asCanonical)) +given prismDIDPut: Put[CanonicalPrismDID] = Put[String].contramap(_.toString) + +given statusPurposeGet: Get[StatusPurpose] = Get[String].map { + case "Revocation" => StatusPurpose.Revocation + case "Suspension" => StatusPurpose.Suspension + case purpose => throw RuntimeException(s"Invalid status purpose - $purpose") +} + +given statusPurposePut: Put[StatusPurpose] = Put[String].contramap { + case StatusPurpose.Revocation => StatusPurpose.Revocation.str + case StatusPurpose.Suspension => StatusPurpose.Suspension.str +} diff --git a/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialRepository.scala b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialRepository.scala index a6b36aead7..7adc183485 100644 --- a/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialRepository.scala +++ b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialRepository.scala @@ -34,9 +34,6 @@ class JdbcCredentialRepository(xa: Transactor[ContextAwareTask], xb: Transactor[ import IssueCredentialRecord.* - given didCommIDGet: Get[DidCommID] = Get[String].map(DidCommID(_)) - given didCommIDPut: Put[DidCommID] = Put[String].contramap(_.value) - given credentialFormatGet: Get[CredentialFormat] = Get[String].map(CredentialFormat.valueOf) given credentialFormatPut: Put[CredentialFormat] = Put[String].contramap(_.toString) @@ -59,10 +56,6 @@ class JdbcCredentialRepository(xa: Transactor[ContextAwareTask], xb: Transactor[ given issueCredentialGet: Get[IssueCredential] = Get[String].map(decode[IssueCredential](_).getOrElse(???)) given issueCredentialPut: Put[IssueCredential] = Put[String].contramap(_.asJson.toString) - given prismDIDGet: Get[CanonicalPrismDID] = - Get[String].map(s => PrismDID.fromString(s).fold(e => throw RuntimeException(e), _.asCanonical)) - given prismDIDPut: Put[CanonicalPrismDID] = Put[String].contramap(_.toString) - override def createIssueCredentialRecord(record: IssueCredentialRecord): RIO[WalletAccessContext, Int] = { val cxnIO = sql""" | INSERT INTO public.issue_credential_records( diff --git a/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialStatusListRepository.scala b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialStatusListRepository.scala new file mode 100644 index 0000000000..d32867e714 --- /dev/null +++ b/pollux/lib/sql-doobie/src/main/scala/io/iohk/atala/pollux/sql/repository/JdbcCredentialStatusListRepository.scala @@ -0,0 +1,297 @@ +package io.iohk.atala.pollux.sql.repository + +import doobie.* +import doobie.implicits.* +import doobie.postgres.* +import doobie.postgres.implicits.* +import io.iohk.atala.pollux.vc.jwt.{Issuer, StatusPurpose} +import io.iohk.atala.pollux.vc.jwt.revocation.{BitString, BitStringError, VCStatusList2021} +import io.iohk.atala.castor.core.model.did.* +import io.iohk.atala.pollux.core.model.* +import io.iohk.atala.pollux.core.repository.CredentialStatusListRepository +import io.iohk.atala.shared.db.ContextAwareTask +import io.iohk.atala.shared.db.Implicits.* +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.* +import zio.* +import zio.interop.catz.* +import io.iohk.atala.shared.models.{WalletAccessContext, WalletId} + +import java.time.Instant +import java.util.UUID + +class JdbcCredentialStatusListRepository(xa: Transactor[ContextAwareTask], xb: Transactor[Task]) + extends CredentialStatusListRepository { + + def findById(id: UUID): Task[Option[CredentialStatusList]] = { + val cxnIO = + sql""" + | SELECT + | id, + | wallet_id, + | issuer, + | issued, + | purpose, + | status_list_credential, + | size, + | last_used_index, + | created_at, + | updated_at + | FROM public.credential_status_lists where id = $id + |""".stripMargin + .query[CredentialStatusList] + .option + + cxnIO.transact(xb) + + } + + def getLatestOfTheWallet: RIO[WalletAccessContext, Option[CredentialStatusList]] = { + + val cxnIO = + sql""" + | SELECT + | id, + | wallet_id, + | issuer, + | issued, + | purpose, + | status_list_credential, + | size, + | last_used_index, + | created_at, + | updated_at + | FROM public.credential_status_lists order by created_at DESC limit 1 + |""".stripMargin + .query[CredentialStatusList] + .option + + cxnIO + .transactWallet(xa) + + } + + def createNewForTheWallet( + jwtIssuer: Issuer, + statusListRegistryUrl: String + ): RIO[WalletAccessContext, CredentialStatusList] = { + + val id = UUID.randomUUID() + val issued = Instant.now() + val issuerDid = jwtIssuer.did.value + + val credentialWithEmbeddedProof = for { + bitString <- BitString.getInstance().mapError { + case InvalidSize(message) => new Throwable(message) + case EncodingError(message) => new Throwable(message) + case DecodingError(message) => new Throwable(message) + case IndexOutOfBounds(message) => new Throwable(message) + } + emptyStatusListCredential <- VCStatusList2021 + .build( + vcId = s"$statusListRegistryUrl/credential-status/$id", + slId = "", + revocationData = bitString, + jwtIssuer = jwtIssuer + ) + .mapError(x => new Throwable(x.msg)) + + credentialWithEmbeddedProof <- emptyStatusListCredential.toJsonWithEmbeddedProof + } yield credentialWithEmbeddedProof.spaces2 + + for { + credentialStr <- credentialWithEmbeddedProof + query = sql""" + |INSERT INTO public.credential_status_lists ( + | id, + | issuer, + | issued, + | purpose, + | status_list_credential, + | size, + | last_used_index, + | wallet_id + | ) + |VALUES ( + | $id, + | $issuerDid, + | $issued, + | ${StatusPurpose.Revocation}::public.enum_credential_status_list_purpose, + | $credentialStr::JSON, + | ${BitString.MIN_SL2021_SIZE}, + | 0, + | current_setting('app.current_wallet_id')::UUID + | ) + |RETURNING id, wallet_id, issuer, issued, purpose, status_list_credential, size, last_used_index, created_at, updated_at + """.stripMargin.query[CredentialStatusList].unique + newStatusList <- query + .transactWallet(xa) + } yield newStatusList + + } + + def allocateSpaceForCredential( + issueCredentialRecordId: DidCommID, + credentialStatusListId: UUID, + statusListIndex: Int + ): RIO[WalletAccessContext, Unit] = { + + val statusListEntryCreationQuery = + sql""" + | INSERT INTO public.credentials_in_status_list ( + | id, + | issue_credential_record_id, + | credential_status_list_id, + | status_list_index, + | is_canceled + | ) + | VALUES ( + | ${UUID.randomUUID()}, + | $issueCredentialRecordId, + | $credentialStatusListId, + | $statusListIndex, + | false + | ) + |""".stripMargin.update.run + + val statusListUpdateQuery = + sql""" + | UPDATE public.credential_status_lists + | SET + | last_used_index = $statusListIndex, + | updated_at = ${Instant.now()} + | WHERE + | id = $credentialStatusListId + |""".stripMargin.update.run + + val res: ConnectionIO[Unit] = for { + _ <- statusListEntryCreationQuery + _ <- statusListUpdateQuery + } yield () + + res.transactWallet(xa) + + } + + def revokeByIssueCredentialRecordId( + issueCredentialRecordId: DidCommID + ): RIO[WalletAccessContext, Boolean] = { + + for { + walletId <- ZIO.service[WalletAccessContext].map(_.walletId) + updateQuery = + sql""" + | UPDATE public.credentials_in_status_list AS cisl + | SET is_canceled = true + | FROM public.credential_status_lists AS csl + | WHERE cisl.credential_status_list_id = csl.id + | AND csl.wallet_id = ${walletId.toUUID} + | AND cisl.issue_credential_record_id = $issueCredentialRecordId + | AND cisl.is_canceled = false; + |""".stripMargin.update.run + + revoked <- updateQuery.transactWallet(xa).map(_ > 0) + + } yield revoked + + } + + def getCredentialStatusListsWithCreds: Task[List[CredentialStatusListWithCreds]] = { + + // Might need to add wallet Id in the select query, because I'm selecting all of them + val cxnIO = + sql""" + | SELECT + | csl.id as credential_status_list_id, + | csl.issuer, + | csl.issued, + | csl.purpose, + | csl.wallet_id, + | csl.status_list_credential, + | csl.size, + | csl.last_used_index, + | cisl.id as credential_in_status_list_id, + | cisl.issue_credential_record_id, + | cisl.status_list_index, + | cisl.is_canceled, + | cisl.is_processed + | FROM public.credential_status_lists csl + | LEFT JOIN public.credentials_in_status_list cisl ON csl.id = cisl.credential_status_list_id + |""".stripMargin + .query[CredentialStatusListWithCred] + .to[List] + + val credentialStatusListsWithCredZio = cxnIO + .transact(xb) + + for { + credentialStatusListsWithCred <- credentialStatusListsWithCredZio + } yield { + credentialStatusListsWithCred + .groupBy(_.credentialStatusListId) + .map { case (id, items) => + CredentialStatusListWithCreds( + id, + items.head.walletId, + items.head.issuer, + items.head.issued, + items.head.purpose, + items.head.statusListCredential, + items.head.size, + items.head.lastUsedIndex, + items.map { item => + CredInStatusList( + item.credentialInStatusListId, + item.issueCredentialRecordId, + item.statusListIndex, + item.isCanceled, + item.isProcessed, + ) + } + ) + } + .toList + } + } + + def updateStatusListCredential( + credentialStatusListId: UUID, + statusListCredential: String + ): RIO[WalletAccessContext, Unit] = { + + val updateQuery = + sql""" + | UPDATE public.credential_status_lists + | SET + | status_list_credential = $statusListCredential::JSON, + | updated_at = ${Instant.now()} + | WHERE + | id = $credentialStatusListId + |""".stripMargin.update.run + + updateQuery.transactWallet(xa).unit + + } + + def markAsProcessedMany( + credsInStatusListIds: Seq[UUID] + ): RIO[WalletAccessContext, Unit] = { + + val updateQuery = + sql""" + | UPDATE public.credentials_in_status_list + | SET + | is_processed = true + | WHERE + | id::text IN (${credsInStatusListIds.map(_.toString).mkString(",")}) + |""".stripMargin.update.run + + if credsInStatusListIds.nonEmpty then updateQuery.transactWallet(xa).unit else ZIO.unit + + } + +} + +object JdbcCredentialStatusListRepository { + val layer: URLayer[Transactor[ContextAwareTask] & Transactor[Task], CredentialStatusListRepository] = + ZLayer.fromFunction(new JdbcCredentialStatusListRepository(_, _)) +} 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 310f287867..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 @@ -5,11 +5,20 @@ import com.nimbusds.jose.crypto.ECDSASigner import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import com.nimbusds.jose.jwk.{Curve, ECKey} import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} -import io.circe import io.circe.* +import io.circe.syntax.* +import zio.* import pdi.jwt.algorithms.JwtECDSAAlgorithm import pdi.jwt.{JwtAlgorithm, JwtCirce} +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + import java.security.* +import io.iohk.atala.shared.utils.Json as JsonUtils +import io.iohk.atala.shared.utils.Base64Utils as Base64Utils + +import scodec.bits.* + +import java.time.* opaque type JWT = String @@ -23,12 +32,22 @@ object JWT { trait Signer { def encode(claim: Json): JWT + + def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] + } class ES256Signer(privateKey: PrivateKey) extends Signer { val algorithm: JwtECDSAAlgorithm = JwtAlgorithm.ES256 + private val provider = BouncyCastleProviderSingleton.getInstance + Security.addProvider(provider) override def encode(claim: Json): JWT = JWT(JwtCirce.encode(claim, privateKey, algorithm)) + + override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { + EddsaJcs2022ProofGenerator.generateProof(payload, privateKey, pk) + } + } // works with java 7, 8, 11 & bouncycastle provider @@ -40,6 +59,11 @@ class ES256KSigner(privateKey: PrivateKey) extends Signer { ecdsaSigner.getJCAContext.setProvider(bouncyCastleProvider) ecdsaSigner } + + override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { + EddsaJcs2022ProofGenerator.generateProof(payload, privateKey, pk) + } + override def encode(claim: Json): JWT = { val claimSet = JWTClaimsSet.parse(claim.noSpaces) val signedJwt = SignedJWT( 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 new file mode 100644 index 0000000000..d1c881e578 --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiBaseString.scala @@ -0,0 +1,50 @@ +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 { + enum Header(val value: Char) { + case Base64Url extends Header('u') + 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) + case 'z' => Some(Header.Base58Btc) + case _ => None + } + } + + given multiBaseStringEncoder: Encoder[MultiBaseString] = (multiBaseString: MultiBaseString) => + Json.fromString(multiBaseString.toMultiBaseString) + + given multiBaseStringDecoder: Decoder[MultiBaseString] = (c: HCursor) => + Decoder.decodeString(c).flatMap { str => + val header = MultiBaseString.Header.fromValue(str.head) + header match { + case Some(value) => Right(MultiBaseString(value, str.tail)) + case None => Left(DecodingFailure(s"no enum value matched for $str", List(CursorOp.Field(str)))) + } + } +} diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiKey.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiKey.scala new file mode 100644 index 0000000000..0addd169ed --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/MultiKey.scala @@ -0,0 +1,37 @@ +package io.iohk.atala.pollux.vc.jwt + +import io.circe.* +import io.circe.syntax.* +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} + +case class MultiKey( + publicKeyMultibase: Option[MultiBaseString] = None, + secretKeyMultibase: Option[MultiBaseString] = None +) { + val `type`: String = "Multikey" + val `@context`: Set[String] = Set("https://w3id.org/security/multikey/v1") +} +object MultiKey { + given multiKeyEncoder: Encoder[MultiKey] = + (multiKey: MultiKey) => + Json + .obj( + ("@context", multiKey.`@context`.asJson), + ("type", multiKey.`type`.asJson), + ("publicKeyMultibase", multiKey.publicKeyMultibase.asJson), + ("secretKeyMultibase", multiKey.secretKeyMultibase.asJson), + ) + + given multiKeyDecoder: Decoder[MultiKey] = + (c: HCursor) => + for { + publicKeyMultibase <- c.downField("publicKeyMultibase").as[Option[MultiBaseString]] + secretKeyMultibase <- c.downField("secretKeyMultibase").as[Option[MultiBaseString]] + } yield { + MultiKey( + publicKeyMultibase = publicKeyMultibase, + secretKeyMultibase = secretKeyMultibase, + ) + } + +} 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 new file mode 100644 index 0000000000..80b53ab5b0 --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Proof.scala @@ -0,0 +1,165 @@ +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 + val `type`: String + val proofPurpose: String + val verificationMethod: String + val created: Option[Instant] = None + val domain: Option[String] = None + val challenge: Option[String] = None + val proofValue: String + val previousProof: Option[String] = None + val nonce: Option[String] = None +} + +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)) + dataToSign = canonicalizedJson.noSpaces.getBytes + signature = sign(sk, dataToSign) + base58BtsEncodedSignature = MultiBaseString( + header = MultiBaseString.Header.Base58Btc, + data = ByteVector.view(signature).toBase58 + ).toMultiBaseString + created = Instant.now() + multiKey = MultiKey(publicKeyMultibase = + Some(MultiBaseString(header = MultiBaseString.Header.Base64Url, data = Base64Utils.encodeURL(pk.getEncoded))) + ) + verificationMethod = Base64Utils.createDataUrl( + multiKey.asJson.dropNullValues.noSpaces.getBytes, + "application/json" + ) + } yield EddsaJcs2022Proof( + proofValue = base58BtsEncodedSignature, + maybeCreated = Some(created), + verificationMethod = verificationMethod + ) + } + + 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) + signer.initSign(privateKey) + 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 { + override val created: Option[Instant] = maybeCreated + override val `type`: String = "DataIntegrityProof" + override val proofPurpose: String = "assertionMethod" + val cryptoSuite: String = "eddsa-jcs-2022" +} + +object EddsaJcs2022Proof { + + given proofEncoder: Encoder[EddsaJcs2022Proof] = + (proof: EddsaJcs2022Proof) => + Json + .obj( + ("id", proof.id.asJson), + ("type", proof.`type`.asJson), + ("proofPurpose", proof.proofPurpose.asJson), + ("verificationMethod", proof.verificationMethod.asJson), + ("created", proof.created.map(_.atOffset(ZoneOffset.UTC)).asJson), + ("domain", proof.domain.asJson), + ("challenge", proof.challenge.asJson), + ("proofValue", proof.proofValue.asJson), + ("cryptoSuite", proof.cryptoSuite.asJson), + ("previousProof", proof.previousProof.asJson), + ("nonce", proof.nonce.asJson), + ("cryptoSuite", proof.cryptoSuite.asJson), + ) + + given proofDecoder: Decoder[EddsaJcs2022Proof] = + (c: HCursor) => + for { + id <- c.downField("id").as[Option[String]] + `type` <- c.downField("type").as[String] + proofPurpose <- c.downField("proofPurpose").as[String] + verificationMethod <- c.downField("verificationMethod").as[String] + created <- c.downField("created").as[Option[Instant]] + domain <- c.downField("domain").as[Option[String]] + challenge <- c.downField("challenge").as[Option[String]] + proofValue <- c.downField("proofValue").as[String] + previousProof <- c.downField("previousProof").as[Option[String]] + nonce <- c.downField("nonce").as[Option[String]] + cryptoSuite <- c.downField("cryptoSuite").as[String] + } yield { + EddsaJcs2022Proof( + proofValue = proofValue, + verificationMethod = verificationMethod, + maybeCreated = created + ) + } +} diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Verifiable.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Verifiable.scala index 221d3128c9..7b3e9b7653 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Verifiable.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/Verifiable.scala @@ -7,27 +7,28 @@ import io.circe.syntax.* import scala.annotation.unused -trait Verifiable(@unused proof: Proof) +trait Verifiable(@unused proof: JwtProof) -case class Proof(`type`: String, jwt: JWT) +// JwtProof2020 is not following the spec +case class JwtProof(`type`: String = "JwtProof2020", jwt: JWT) -object Proof { +object JwtProof { object Implicits { - implicit val proofEncoder: Encoder[Proof] = - (proof: Proof) => + implicit val proofEncoder: Encoder[JwtProof] = + (proof: JwtProof) => Json .obj( ("type", proof.`type`.asJson), ("jwt", proof.jwt.value.asJson) ) - implicit val proofDecoder: Decoder[Proof] = + implicit val proofDecoder: Decoder[JwtProof] = (c: HCursor) => for { `type` <- c.downField("type").as[String] jwt <- c.downField("jwt").as[String] } yield { - Proof( + JwtProof( `type` = `type`, jwt = JWT(jwt) ) 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 4002c87b57..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 @@ -4,12 +4,15 @@ import io.circe import io.circe.generic.auto.* import io.circe.parser.decode import io.circe.syntax.* -import io.circe.{Decoder, Encoder, HCursor, Json} +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} @@ -28,15 +31,25 @@ case class Issuer(did: DID, signer: Signer, publicKey: PublicKey) sealed trait VerifiableCredentialPayload -case class W3cVerifiableCredentialPayload(payload: W3cCredentialPayload, proof: Proof) +case class W3cVerifiableCredentialPayload(payload: W3cCredentialPayload, proof: JwtProof) extends Verifiable(proof), VerifiableCredentialPayload case class JwtVerifiableCredentialPayload(jwt: JWT) extends VerifiableCredentialPayload +case class AnoncredVerifiableCredentialPayload(json: String) extends VerifiableCredentialPayload //FIXME json type + +enum StatusPurpose(val str: String) { + case Revocation extends StatusPurpose("Revocation") + case Suspension extends StatusPurpose("Suspension") +} + case class CredentialStatus( id: String, - `type`: String + `type`: String, + statusPurpose: StatusPurpose, + statusListIndex: Int, + statusListCredential: String ) case class RefreshService( @@ -248,10 +261,10 @@ case class JwtCredentialPayload( case class W3cCredentialPayload( override val `@context`: Set[String], override val `type`: Set[String], - val maybeId: Option[String], - val issuer: DID, - val issuanceDate: Instant, - val maybeExpirationDate: Option[Instant], + maybeId: Option[String], + issuer: DID, + issuanceDate: Instant, + maybeExpirationDate: Option[Instant], override val maybeCredentialSchema: Option[CredentialSchema], override val credentialSubject: Json, override val maybeCredentialStatus: Option[CredentialStatus], @@ -271,7 +284,7 @@ object CredentialPayload { object Implicits { import InstantDecoderEncoder.* - import Proof.Implicits.* + import JwtProof.Implicits.* implicit val didEncoder: Encoder[DID] = (did: DID) => did.value.asJson @@ -292,12 +305,17 @@ object CredentialPayload { ("type", credentialSchema.`type`.asJson) ) + implicit val credentialStatusPurposeEncoder: Encoder[StatusPurpose] = (a: StatusPurpose) => a.toString.asJson + implicit val credentialStatusEncoder: Encoder[CredentialStatus] = (credentialStatus: CredentialStatus) => Json .obj( ("id", credentialStatus.id.asJson), - ("type", credentialStatus.`type`.asJson) + ("type", credentialStatus.`type`.asJson), + ("statusPurpose", credentialStatus.statusPurpose.asJson), + ("statusListIndex", credentialStatus.statusListIndex.asJson), + ("statusListCredential", credentialStatus.statusListCredential.asJson) ) implicit val w3cCredentialPayloadEncoder: Encoder[W3cCredentialPayload] = @@ -383,13 +401,29 @@ object CredentialPayload { CredentialSchema(id = id, `type` = `type`) } + implicit val credentialStatusPurposeDecoder: Decoder[StatusPurpose] = (c: HCursor) => + Decoder.decodeString(c).flatMap { str => + Try(StatusPurpose.valueOf(str)).toEither.leftMap { _ => + DecodingFailure(s"no enum value matched for $str", List(CursorOp.Field(str))) + } + } + implicit val credentialStatusDecoder: Decoder[CredentialStatus] = (c: HCursor) => for { id <- c.downField("id").as[String] `type` <- c.downField("type").as[String] + statusPurpose <- c.downField("statusPurpose").as[StatusPurpose] + statusListIndex <- c.downField("statusListIndex").as[Int] + statusListCredential <- c.downField("statusListCredential").as[String] } yield { - CredentialStatus(id = id, `type` = `type`) + CredentialStatus( + id = id, + `type` = `type`, + statusPurpose = statusPurpose, + statusListIndex = statusListIndex, + statusListCredential = statusListCredential + ) } implicit val w3cCredentialPayloadDecoder: Decoder[W3cCredentialPayload] = @@ -491,7 +525,7 @@ object CredentialPayload { (c: HCursor) => for { payload <- c.as[W3cCredentialPayload] - proof <- c.downField("proof").as[Proof] + proof <- c.downField("proof").as[JwtProof] } yield { W3cVerifiableCredentialPayload( payload = payload, @@ -602,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 { @@ -694,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 <- @@ -707,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 } } @@ -718,7 +832,7 @@ object W3CCredential { def encodeW3C(payload: W3cCredentialPayload, issuer: Issuer): W3cVerifiableCredentialPayload = { W3cVerifiableCredentialPayload( payload = payload, - proof = Proof( + proof = JwtProof( `type` = "JwtProof2020", jwt = issuer.signer.encode(payload.asJson) ) @@ -728,6 +842,18 @@ object W3CCredential { def toEncodedJwt(payload: W3cCredentialPayload, issuer: Issuer): JWT = JwtCredential.encodeJwt(payload.toJwtCredentialPayload, issuer) + def toJsonWithEmbeddedProof(payload: W3cCredentialPayload, issuer: Issuer): Task[Json] = { + val jsonCred = payload.asJson + + for { + proof <- issuer.signer.generateProofForJson(jsonCred, issuer.publicKey) + jsonProof <- proof match + case a: EddsaJcs2022Proof => ZIO.succeed(a.asJson.dropNullValues) + verifiableCredentialWithProof = jsonCred.deepMerge(Map("proof" -> jsonProof).asJson) + } yield verifiableCredentialWithProof + + } + def validateW3C( payload: W3cVerifiableCredentialPayload, proofPurpose: Option[VerificationRelationship] = None @@ -745,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 <- @@ -755,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 e43675788d..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} @@ -18,7 +20,7 @@ sealed trait VerifiablePresentationPayload case class Prover(did: DID, signer: Signer, publicKey: PublicKey) -case class W3cVerifiablePresentationPayload(payload: W3cPresentationPayload, proof: Proof) +case class W3cVerifiablePresentationPayload(payload: W3cPresentationPayload, proof: JwtProof) extends Verifiable(proof), VerifiablePresentationPayload @@ -146,7 +148,7 @@ object PresentationPayload { import CredentialPayload.Implicits.* import InstantDecoderEncoder.* - import Proof.Implicits.* + import JwtProof.Implicits.* implicit val w3cPresentationPayloadEncoder: Encoder[W3cPresentationPayload] = (w3cPresentationPayload: W3cPresentationPayload) => @@ -285,7 +287,7 @@ object PresentationPayload { (c: HCursor) => for { payload <- c.as[W3cPresentationPayload] - proof <- c.downField("proof").as[Proof] + proof <- c.downField("proof").as[JwtProof] } yield { W3cVerifiablePresentationPayload( payload = payload, @@ -319,7 +321,7 @@ object JwtPresentation { def toEncodeW3C(payload: W3cPresentationPayload, issuer: Issuer): W3cVerifiablePresentationPayload = { W3cVerifiablePresentationPayload( payload = payload, - proof = Proof( + proof = JwtProof( `type` = "JwtProof2020", jwt = issuer.signer.encode(payload.asJson) ) @@ -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/CredentialDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/CredentialDemo.scala index 694a759e13..bf18fa4f97 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/CredentialDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/CredentialDemo.scala @@ -29,7 +29,10 @@ import java.time.Instant maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialDIDDocumentValidationDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialDIDDocumentValidationDemo.scala index bea0277747..bdd1f49740 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialDIDDocumentValidationDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialDIDDocumentValidationDemo.scala @@ -82,7 +82,10 @@ object JwtCredentialDIDDocumentValidationDemo extends ZIOAppDefault { maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialEncodingDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialEncodingDemo.scala index 313ab88e57..87efa0a00a 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialEncodingDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialEncodingDemo.scala @@ -54,7 +54,10 @@ import java.time.Instant maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialTemporalVerificationDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialTemporalVerificationDemo.scala index 2be8ac4965..f4539cdd9a 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialTemporalVerificationDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtCredentialTemporalVerificationDemo.scala @@ -57,7 +57,10 @@ import java.time.* maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationCredentialDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationCredentialDemo.scala index 1c7c0d6ab5..0554e26e91 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationCredentialDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationCredentialDemo.scala @@ -68,7 +68,10 @@ import java.time.Instant maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( @@ -85,7 +88,7 @@ import java.time.Instant val w3cVerifiableCredentialPayload = W3cVerifiableCredentialPayload( payload = w3cCredentialPayload, - proof = Proof( + proof = JwtProof( `type` = "JwtProof2020", jwt = w3cIssuerSignedCredential ) diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationTemporalVerificationDemo.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationTemporalVerificationDemo.scala index d787801730..ae4ac3ce0e 100644 --- a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationTemporalVerificationDemo.scala +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/demos/JwtPresentationTemporalVerificationDemo.scala @@ -70,7 +70,10 @@ import scala.collection.immutable.Set maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( @@ -87,7 +90,7 @@ import scala.collection.immutable.Set val w3cVerifiableCredentialPayload = W3cVerifiableCredentialPayload( payload = w3cCredentialPayload, - proof = Proof( + proof = JwtProof( `type` = "JwtProof2020", jwt = w3cIssuerSignedCredential ) @@ -117,7 +120,10 @@ import scala.collection.immutable.Set maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( 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 4892e510c6..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 @@ -99,7 +99,10 @@ object JwtPresentationVerificationDemo extends ZIOAppDefault { maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( @@ -116,7 +119,7 @@ object JwtPresentationVerificationDemo extends ZIOAppDefault { val w3cVerifiableCredentialPayload = W3cVerifiableCredentialPayload( payload = w3cCredentialPayload, - proof = Proof( + proof = JwtProof( `type` = "JwtProof2020", jwt = w3cIssuerSignedCredential ) @@ -146,7 +149,10 @@ object JwtPresentationVerificationDemo extends ZIOAppDefault { maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( @@ -259,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/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala new file mode 100644 index 0000000000..1a0b6cd198 --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitString.scala @@ -0,0 +1,81 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.{DecodingError, EncodingError, IndexOutOfBounds} +import zio.{IO, UIO, ZIO} + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.util +import java.util.Base64 +import java.util.zip.{GZIPInputStream, GZIPOutputStream} + +class BitString private (val bitSet: util.BitSet, val size: Int) { + def setRevokedInPlace(index: Int, value: Boolean): IO[IndexOutOfBounds, Unit] = + if (index >= size) ZIO.fail(IndexOutOfBounds(s"bitIndex >= $size: $index")) + else ZIO.attempt(bitSet.set(index, value)).mapError(t => IndexOutOfBounds(t.getMessage)) + + def isRevoked(index: Int): IO[IndexOutOfBounds, Boolean] = + if (index >= size) ZIO.fail(IndexOutOfBounds(s"bitIndex >= $size: $index")) + else ZIO.attempt(bitSet.get(index)).mapError(t => IndexOutOfBounds(t.getMessage)) + + def revokedCount(): UIO[Int] = ZIO.succeed(bitSet.stream().count().toInt) + + def encoded: IO[EncodingError, String] = { + for { + bitSetByteArray <- ZIO.succeed(bitSet.toByteArray) + /* + This is where the size constructor parameter comes into play (i.e. the initial bitstring size requested by the user). + Interestingly, the underlying 'bitSet.toByteArray()' method only returns the byte array that are 'in use', which means the bytes needed to hold the current bits that are set to true. + E.g. Calling toByteArray on a BitSet of size 64, where all bits are false, will return an empty array. The same BitSet with the fourth bit set to true will return 1 byte. And so on... + So, the paddingByteArray is used to fill the gap between what BitSet returns and what was requested by the user. + If the BitString size is 131.072 and no VC is revoked, the final encoding (as per the spec) should account for all bits, and no only those that are revoked. + The (x + 7) / 8) is used to calculate the number of bytes needed to store a bit array of size x. + */ + paddingByteArray = new Array[Byte](((size + 7) / 8) - bitSetByteArray.length) + baos = new ByteArrayOutputStream() + _ <- (for { + gzipOutputStream <- ZIO.attempt(new GZIPOutputStream(baos)) + _ <- ZIO.attempt(gzipOutputStream.write(bitSetByteArray)) + _ <- ZIO.attempt(gzipOutputStream.write(paddingByteArray)) + _ <- ZIO.attempt(gzipOutputStream.close()) + } yield ()).mapError(t => EncodingError(t.getMessage)) + } yield { + Base64.getUrlEncoder.encodeToString(baos.toByteArray) + } + } +} + +object BitString { + /* + The minimum size of the bit string according to the VC Status List 2021 specification. + As per the spec "... a minimum revocation bitstring of 131.072, or 16KB uncompressed... is enough to give holders an adequate amount of herd privacy" + Cf. https://www.w3.org/TR/vc-status-list/#revocation-bitstring-length + */ + val MIN_SL2021_SIZE: Int = 131072 + + def getInstance(): IO[BitStringError, BitString] = getInstance(MIN_SL2021_SIZE) + + def getInstance(size: Int): IO[BitStringError, BitString] = { + if (size % 8 != 0) ZIO.fail(BitStringError.InvalidSize("Bit string size should be a multiple of 8")) + else ZIO.succeed(BitString(new util.BitSet(size), size)) + } + + def valueOf(b64Value: String): IO[DecodingError, BitString] = { + for { + ba <- ZIO.attempt(Base64.getUrlDecoder.decode(b64Value)).mapError(t => DecodingError(t.getMessage)) + } yield { + val bais = new ByteArrayInputStream(ba) + val gzipInputStream = new GZIPInputStream(bais) + val byteArray = gzipInputStream.readAllBytes() + BitString(util.BitSet.valueOf(byteArray), byteArray.length * 8) + } + } +} + +sealed trait BitStringError + +object BitStringError { + final case class InvalidSize(message: String) extends BitStringError + final case class EncodingError(message: String) extends BitStringError + final case class DecodingError(message: String) extends BitStringError + final case class IndexOutOfBounds(message: String) extends BitStringError +} diff --git a/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala new file mode 100644 index 0000000000..2822eb36ae --- /dev/null +++ b/pollux/lib/vc-jwt/src/main/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021.scala @@ -0,0 +1,107 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.circe.syntax.* +import io.circe.{Json, JsonObject} +import io.iohk.atala.pollux.vc.jwt.* +import io.iohk.atala.pollux.vc.jwt.revocation.VCStatusList2021Error.{DecodingError, EncodingError} +import zio.* + +import java.time.Instant + +class VCStatusList2021 private (val vcPayload: W3cCredentialPayload, jwtIssuer: Issuer) { + + def encoded: UIO[JWT] = ZIO.succeed(W3CCredential.toEncodedJwt(vcPayload, jwtIssuer)) + + def toJsonWithEmbeddedProof: Task[Json] = W3CCredential.toJsonWithEmbeddedProof(vcPayload, jwtIssuer) + + def updateBitString(bitString: BitString): IO[VCStatusList2021Error, VCStatusList2021] = { + import CredentialPayload.Implicits.* + + val res = for { + vcId <- ZIO.fromOption(vcPayload.maybeId).mapError(_ => DecodingError("VC id not found")) + slId <- ZIO + .fromEither(vcPayload.credentialSubject.hcursor.downField("id").as[String]) + .mapError(x => DecodingError(x.message)) + purpose <- ZIO + .fromEither(vcPayload.credentialSubject.hcursor.downField("statusPurpose").as[StatusPurpose]) + .mapError(x => DecodingError(x.message)) + } yield VCStatusList2021.build(vcId, slId, jwtIssuer, bitString, purpose) + + res.flatten + } + + def getBitString: IO[DecodingError, BitString] = { + for { + encodedBitString <- ZIO + .fromOption( + vcPayload.credentialSubject.hcursor.downField("encodedList").as[String].toOption + ) + .mapError(_ => DecodingError("'encodedList' attribute not found in credential subject")) + bitString <- BitString.valueOf(encodedBitString).mapError(e => DecodingError(e.message)) + } yield bitString + } +} + +object VCStatusList2021 { + + def build( + vcId: String, + slId: String, + jwtIssuer: Issuer, + revocationData: BitString, + purpose: StatusPurpose = StatusPurpose.Revocation + ): IO[EncodingError, VCStatusList2021] = { + for { + encodedBitString <- revocationData.encoded.mapError(e => EncodingError(e.message)) + } yield { + val claims = JsonObject() + .add("id", slId.asJson) + .add("type", "StatusList2021".asJson) + .add("statusPurpose", purpose.str.asJson) + .add("encodedList", encodedBitString.asJson) + val w3Credential = W3cCredentialPayload( + `@context` = Set( + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/vc/status-list/2021/v1" + ), + maybeId = Some(vcId), + `type` = Set("VerifiableCredential", "StatusList2021Credential"), + issuer = jwtIssuer.did, + issuanceDate = Instant.now, + maybeExpirationDate = None, + maybeCredentialSchema = None, + credentialSubject = claims.asJson, + maybeCredentialStatus = None, + maybeRefreshService = None, + maybeEvidence = None, + maybeTermsOfUse = None + ) + VCStatusList2021(w3Credential, jwtIssuer) + } + } + + def decodeFromJson(json: Json, issuer: Issuer): IO[DecodingError, VCStatusList2021] = { + import CredentialPayload.Implicits.* + for { + w3cCredentialPayload <- ZIO + .fromEither(io.circe.parser.decode[W3cCredentialPayload](json.noSpaces)) + .mapError(t => DecodingError(t.getMessage)) + } yield VCStatusList2021(w3cCredentialPayload, issuer) + } + + def decode(encodedJwtVC: JWT, issuer: Issuer): IO[DecodingError, VCStatusList2021] = { + for { + jwtCredentialPayload <- ZIO + .fromTry(JwtCredential.decodeJwt(encodedJwtVC, issuer.publicKey)) + .mapError(t => DecodingError(t.getMessage)) + } yield VCStatusList2021(jwtCredentialPayload.toW3CCredentialPayload, issuer) + } + +} + +sealed trait VCStatusList2021Error + +object VCStatusList2021Error { + final case class EncodingError(msg: String) extends VCStatusList2021Error + final case class DecodingError(msg: String) extends VCStatusList2021Error +} 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/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala new file mode 100644 index 0000000000..186d582faf --- /dev/null +++ b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/BitStringSpec.scala @@ -0,0 +1,94 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.revocation.BitStringError.{IndexOutOfBounds, InvalidSize} +import zio.* +import zio.test.* +import zio.test.Assertion.* + +object BitStringSpec extends ZIOSpecDefault { + + private val MIN_SIZE_SL2021_WITH_NO_REVOCATION = + "H4sIAAAAAAAA_-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" + + override def spec = suite("Revocation BitString test suite")( + test("A default bit string instance has zero revoked items") { + for { + bitString <- BitString.getInstance() + revokedCount <- bitString.revokedCount() + } yield { + assertTrue(revokedCount == 0) + } + }, + test("A default bit string instance is correctly encoded/decoded") { + for { + initialBS <- BitString.getInstance() + encodedBS <- initialBS.encoded + decodedBS <- BitString.valueOf(encodedBS) + decodedRevokedCount <- decodedBS.revokedCount() + reencodedBS <- decodedBS.encoded + } yield { + assertTrue(encodedBS == MIN_SIZE_SL2021_WITH_NO_REVOCATION) + && assertTrue(decodedBS.size == BitString.MIN_SL2021_SIZE) + && assertTrue(decodedBS.size == initialBS.size) + && assertTrue(decodedRevokedCount == 0) + && assertTrue(encodedBS == reencodedBS) + } + }, + test("A bit string with custom size and revoked items is correctly encoded") { + for { + initialBS <- BitString.getInstance(800) + _ <- initialBS.setRevokedInPlace(753, true) + _ <- initialBS.setRevokedInPlace(45, true) + encodedBS <- initialBS.encoded + decodedBS <- BitString.valueOf(encodedBS) + decodedRevokedCount <- decodedBS.revokedCount() + isDecodedRevoked1 <- decodedBS.isRevoked(753) + isDecodedRevoked2 <- decodedBS.isRevoked(45) + isDecodedRevoked3 <- decodedBS.isRevoked(32) + } yield { + assertTrue(decodedRevokedCount == 2) + && assertTrue(isDecodedRevoked1) + && assertTrue(isDecodedRevoked2) + && assertTrue(!isDecodedRevoked3) + } + }, + test("A custom bit string size is a multiple of 8") { + for { + bitString <- BitString.getInstance(31).exit + } yield assert(bitString)(failsWithA[InvalidSize]) + }, + test("The first index is 0 and last index at 'size - 1'") { + for { + bitString <- BitString.getInstance(24) + _ <- bitString.setRevokedInPlace(0, true) + _ <- bitString.setRevokedInPlace(bitString.size - 1, true) + result <- bitString.setRevokedInPlace(bitString.size, true).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Revoking with a negative index fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.setRevokedInPlace(-1, true).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Revoking with an index above the range fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.setRevokedInPlace(20, false).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Getting revocation state with a negative index fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.isRevoked(-1).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + }, + test("Getting revocation state with an index above the range fails") { + for { + bitString <- BitString.getInstance(8) + result <- bitString.isRevoked(20).exit + } yield assert(result)(failsWithA[IndexOutOfBounds]) + } + ) + +} diff --git a/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala new file mode 100644 index 0000000000..a08c2bf0ac --- /dev/null +++ b/pollux/lib/vc-jwt/src/test/scala/io/iohk/atala/pollux/vc/jwt/revocation/VCStatusList2021Spec.scala @@ -0,0 +1,85 @@ +package io.iohk.atala.pollux.vc.jwt.revocation + +import io.iohk.atala.pollux.vc.jwt.{DID, ES256Signer, Issuer, JwtCredential} +import zio.test.{Spec, ZIOSpecDefault, assertTrue} +import zio.{UIO, ZIO} + +import java.security.spec.ECGenParameterSpec +import java.security.{KeyPairGenerator, SecureRandom} + +object VCStatusList2021Spec extends ZIOSpecDefault { + + private val VC_ID = "https://example.com/credentials/status/3" + + private def generateIssuer(): UIO[Issuer] = { + val keyGen = KeyPairGenerator.getInstance("EC") + val ecSpec = ECGenParameterSpec("secp256r1") + keyGen.initialize(ecSpec, SecureRandom()) + val keyPair = keyGen.generateKeyPair() + val privateKey = keyPair.getPrivate + val publicKey = keyPair.getPublic + ZIO.succeed( + Issuer( + did = DID("did:issuer:MDP8AsFhHzhwUvGNuYkX7T"), + signer = ES256Signer(privateKey), + publicKey = publicKey + ) + ) + } + + override def spec = suite("VCStatusList2021")( + // TODO: add test to verify the proof is valid + test("Should generate status list VC as JSON with embedded proof") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + json <- statusList.toJsonWithEmbeddedProof + } yield { + assertTrue(json.hcursor.downField("proof").focus.isDefined) + } + }, + test("Generate VC contains required fields in 'credentialSubject'") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + jwtVCPayload <- ZIO.fromTry(JwtCredential.decodeJwt(encodedJwtVC, issuer.publicKey)) + credentialSubjectKeys <- ZIO.fromOption(jwtVCPayload.credentialSubject.hcursor.keys) + } yield { + assertTrue(credentialSubjectKeys.toSet == Set("id", "type", "statusPurpose", "encodedList")) + } + }, + test("Generated VC is valid") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + _ <- ZIO.logInfo(s"$encodedJwtVC") + valid <- ZIO.succeed(JwtCredential.validateEncodedJwt(encodedJwtVC, issuer.publicKey)) + } yield { + assertTrue(valid) + } + }, + test("Revocation state is preserved during encoding/decoding") { + for { + issuer <- generateIssuer() + bitString <- BitString.getInstance() + _ <- bitString.setRevokedInPlace(1234, true) + statusList <- VCStatusList2021.build(VC_ID, s"$VC_ID#list", issuer, bitString) + encodedJwtVC <- statusList.encoded + decodedStatusList <- VCStatusList2021.decode(encodedJwtVC, issuer) + decodedBS <- decodedStatusList.getBitString + revokedCount <- decodedBS.revokedCount() + isRevoked1 <- decodedBS.isRevoked(1233) + isRevoked2 <- decodedBS.isRevoked(1234) + } yield { + assertTrue(revokedCount == 1) && + assertTrue(!isRevoked1) && + assertTrue(isRevoked2) + } + } + ) +} diff --git a/prism-agent/service/server/src/main/resources/application.conf b/prism-agent/service/server/src/main/resources/application.conf index 697cb6030d..1be6917b31 100644 --- a/prism-agent/service/server/src/main/resources/application.conf +++ b/prism-agent/service/server/src/main/resources/application.conf @@ -28,6 +28,11 @@ pollux { awaitConnectionThreads = 4 awaitConnectionThreads = ${?POLLUX_DB_AWAIT_CONNECTION_THREADS} } + statusListRegistry { + # defaults to the exposed AGENT_HTTP_PORT port + publicEndpointUrl = "http://localhost:"${agent.httpEndpoint.http.port} + publicEndpointUrl = ${?POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL} + } issueBgJobRecordsLimit = 25 issueBgJobRecordsLimit = ${?ISSUE_BG_JOB_RECORDS_LIMIT} issueBgJobRecurrenceDelay = 2 seconds @@ -40,6 +45,10 @@ pollux { presentationBgJobRecurrenceDelay = ${?PRESENTATION_BG_JOB_RECURRENCE_DELAY} presentationBgJobProcessingParallelism = 5 presentationBgJobProcessingParallelism = ${?PRESENTATION_BG_JOB_PROCESSING_PARALLELISM} + syncRevocationStatusesBgJobRecurrenceDelay = 2 seconds + syncRevocationStatusesBgJobRecurrenceDelay = ${?SYNC_REVOCATION_STATUSES_BG_JOB_RECURRENCE_DELAY} + syncRevocationStatusesBgJobProcessingParallelism = 5 + syncRevocationStatusesBgJobProcessingParallelism = ${?SYNC_REVOCATION_STATUSES_BG_JOB_PROCESSING_PARALLELISM} } connect { diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/DidCommHttpServer.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/DidCommHttpServer.scala index c0e2971a99..80d1158384 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/DidCommHttpServer.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/DidCommHttpServer.scala @@ -21,13 +21,13 @@ import io.iohk.atala.mercury.model.error.* import io.iohk.atala.mercury.protocol.connection.{ConnectionRequest, ConnectionResponse} import io.iohk.atala.mercury.protocol.issuecredential.* import io.iohk.atala.mercury.protocol.presentproof.* +import io.iohk.atala.mercury.protocol.revocationnotificaiton.RevocationNotification import io.iohk.atala.pollux.core.model.error.{CredentialServiceError, PresentationError} import io.iohk.atala.pollux.core.service.{CredentialService, PresentationService} import io.iohk.atala.resolvers.DIDResolver import io.iohk.atala.shared.models.WalletAccessContext import zio.* import zio.http.* - import java.util.UUID object DidCommHttpServer { @@ -128,6 +128,7 @@ object DidCommHttpServer { _ <- (handleConnect orElse handleIssueCredential orElse handlePresentProof orElse + revocationNotification orElse handleUnknownMessage)(msgAndContext._1).provideSomeLayer(ZLayer.succeed(msgAndContext._2)) } yield () } @@ -222,6 +223,14 @@ object DidCommHttpServer { } yield () } + private val revocationNotification: PartialFunction[Message, ZIO[Any, Throwable, Unit]] = { + case msg if msg.piuri == RevocationNotification.`type` => + for { + revocationNotification <- ZIO.attempt(RevocationNotification.readFromMessage(msg)) + _ <- ZIO.logInfo("Got RevocationNotification: " + revocationNotification) + } yield () + } + /* * Unknown Message */ diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala index 33eef9094d..5661ded955 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala @@ -18,6 +18,7 @@ import io.iohk.atala.castor.core.util.DIDOperationValidator import io.iohk.atala.connect.controller.ConnectionControllerImpl import io.iohk.atala.connect.core.service.{ConnectionServiceImpl, ConnectionServiceNotifier} import io.iohk.atala.connect.sql.repository.{JdbcConnectionRepository, Migrations as ConnectMigrations} +import io.iohk.atala.credentialstatus.controller.CredentialStatusControllerImpl import io.iohk.atala.event.controller.EventControllerImpl import io.iohk.atala.event.notification.EventNotificationServiceImpl import io.iohk.atala.iam.authentication.DefaultAuthenticator @@ -39,6 +40,7 @@ import io.iohk.atala.pollux.sql.repository.{ JdbcCredentialDefinitionRepository, JdbcCredentialRepository, JdbcCredentialSchemaRepository, + JdbcCredentialStatusListRepository, JdbcPresentationRepository, JdbcVerificationPolicyRepository, Migrations as PolluxMigrations @@ -95,7 +97,6 @@ object MainApp extends ZIOAppDefault { _ <- ConnectMigrations.validateRLS.provide(RepoModule.connectContextAwareTransactorLayer) _ <- AgentMigrations.validateRLS.provide(RepoModule.agentContextAwareTransactorLayer) } yield () - override def run: ZIO[Any, Throwable, Unit] = { val app = for { @@ -142,6 +143,7 @@ object MainApp extends ZIOAppDefault { DIDControllerImpl.layer, DIDRegistrarControllerImpl.layer, IssueControllerImpl.layer, + CredentialStatusControllerImpl.layer, PresentProofControllerImpl.layer, VerificationPolicyControllerImpl.layer, EntityControllerImpl.layer, @@ -157,6 +159,7 @@ object MainApp extends ZIOAppDefault { ConnectionServiceImpl.layer >>> ConnectionServiceNotifier.layer, CredentialSchemaServiceImpl.layer, CredentialDefinitionServiceImpl.layer, + CredentialStatusListServiceImpl.layer, LinkSecretServiceImpl.layer >>> CredentialServiceImpl.layer >>> CredentialServiceNotifier.layer, DIDServiceImpl.layer, EntityServiceImpl.layer, @@ -181,6 +184,7 @@ object MainApp extends ZIOAppDefault { RepoModule.agentTransactorLayer >>> JdbcAuthenticationRepository.layer, RepoModule.connectContextAwareTransactorLayer ++ RepoModule.connectTransactorLayer >>> JdbcConnectionRepository.layer, RepoModule.polluxContextAwareTransactorLayer ++ RepoModule.polluxTransactorLayer >>> JdbcCredentialRepository.layer, + RepoModule.polluxContextAwareTransactorLayer ++ RepoModule.polluxTransactorLayer >>> JdbcCredentialStatusListRepository.layer, RepoModule.polluxContextAwareTransactorLayer ++ RepoModule.polluxTransactorLayer >>> JdbcCredentialSchemaRepository.layer, RepoModule.polluxContextAwareTransactorLayer ++ RepoModule.polluxTransactorLayer >>> JdbcCredentialDefinitionRepository.layer, RepoModule.polluxContextAwareTransactorLayer ++ RepoModule.polluxTransactorLayer >>> JdbcPresentationRepository.layer, 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 68372d2afc..d7a1275b48 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 cc1a5ed034..47495dcf52 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 @@ -7,7 +7,8 @@ import io.iohk.atala.agent.server.jobs.{ ConnectBackgroundJobs, DIDStateSyncBackgroundJobs, IssueBackgroundJobs, - PresentBackgroundJobs + PresentBackgroundJobs, + StatusListJobs } import io.iohk.atala.agent.walletapi.model.{Entity, Wallet, WalletSeed} import io.iohk.atala.agent.walletapi.service.{EntityService, ManagedDIDService, WalletManagementService} @@ -16,6 +17,7 @@ import io.iohk.atala.castor.controller.{DIDRegistrarServerEndpoints, DIDServerEn import io.iohk.atala.castor.core.service.DIDService import io.iohk.atala.connect.controller.ConnectionServerEndpoints import io.iohk.atala.connect.core.service.ConnectionService +import io.iohk.atala.credentialstatus.controller.CredentialStatusServiceEndpoints import io.iohk.atala.event.controller.EventServerEndpoints import io.iohk.atala.event.notification.EventNotificationConfig import io.iohk.atala.iam.authentication.apikey.ApiKeyAuthenticator @@ -23,7 +25,7 @@ import io.iohk.atala.iam.entity.http.EntityServerEndpoints import io.iohk.atala.iam.wallet.http.WalletManagementServerEndpoints import io.iohk.atala.issue.controller.IssueServerEndpoints import io.iohk.atala.mercury.{DidOps, HttpClient} -import io.iohk.atala.pollux.core.service.{CredentialService, PresentationService} +import io.iohk.atala.pollux.core.service.{CredentialService, CredentialStatusListService, PresentationService} import io.iohk.atala.pollux.credentialdefinition.CredentialDefinitionRegistryServerEndpoints import io.iohk.atala.pollux.credentialschema.{SchemaRegistryServerEndpoints, VerificationPolicyServerEndpoints} import io.iohk.atala.pollux.vc.jwt.DidResolver as JwtDidResolver @@ -43,7 +45,8 @@ object PrismAgentApp { _ <- issueCredentialDidCommExchangesJob.debug.fork _ <- presentProofExchangeJob.debug.fork _ <- connectDidCommExchangesJob.debug.fork - _ <- syncDIDPublicationStateFromDltJob.fork + _ <- syncDIDPublicationStateFromDltJob.debug.fork + _ <- syncRevocationStatusListsJob.debug.fork _ <- AgentHttpServer.run.fork fiber <- DidCommHttpServer.run.fork _ <- WebhookPublisher.layer.build.map(_.get[WebhookPublisher]).flatMap(_.run.debug.fork) @@ -93,6 +96,16 @@ object PrismAgentApp { .unit } yield () + private val syncRevocationStatusListsJob = { + for { + config <- ZIO.service[AppConfig] + _ <- (StatusListJobs.syncRevocationStatuses @@ Metric + .gauge("revocation_status_list_sync_job_ms_gauge") + .trackDurationWith(_.toMetricsSeconds)) + .repeat(Schedule.spaced(config.pollux.syncRevocationStatusesBgJobRecurrenceDelay)) + } yield () + } + private val syncDIDPublicationStateFromDltJob: URIO[ManagedDIDService & WalletManagementService, Unit] = ZIO .serviceWithZIO[WalletManagementService](_.listWallets().map(_._1)) @@ -116,6 +129,7 @@ object AgentHttpServer { allVerificationPolicyEndpoints <- VerificationPolicyServerEndpoints.all allConnectionEndpoints <- ConnectionServerEndpoints.all allIssueEndpoints <- IssueServerEndpoints.all + allStatusListEndpoints <- CredentialStatusServiceEndpoints.all allDIDEndpoints <- DIDServerEndpoints.all allDIDRegistrarEndpoints <- DIDRegistrarServerEndpoints.all allPresentProofEndpoints <- PresentProofServerEndpoints.all @@ -130,6 +144,7 @@ object AgentHttpServer { allDIDEndpoints ++ allDIDRegistrarEndpoints ++ allIssueEndpoints ++ + allStatusListEndpoints ++ allPresentProofEndpoints ++ allSystemEndpoints ++ allEntityEndpoints ++ diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/config/AppConfig.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/config/AppConfig.scala index 08b5647f3e..8d547a9589 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/config/AppConfig.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/config/AppConfig.scala @@ -55,12 +55,15 @@ object ValidatedVaultConfig { final case class PolluxConfig( database: DatabaseConfig, + statusListRegistry: StatusListRegistryConfig, issueBgJobRecordsLimit: Int, issueBgJobRecurrenceDelay: Duration, issueBgJobProcessingParallelism: Int, presentationBgJobRecordsLimit: Int, presentationBgJobRecurrenceDelay: Duration, presentationBgJobProcessingParallelism: Int, + syncRevocationStatusesBgJobRecurrenceDelay: Duration, + syncRevocationStatusesBgJobProcessingParallelism: Int, ) final case class ConnectConfig( database: DatabaseConfig, @@ -74,6 +77,10 @@ final case class PrismNodeConfig(service: GrpcServiceConfig) final case class GrpcServiceConfig(host: String, port: Int, usePlainText: Boolean) +final case class StatusListRegistryConfig( + publicEndpointUrl: String +) + final case class DatabaseConfig( host: String, port: Int, diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/IssueBackgroundJobs.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/IssueBackgroundJobs.scala index ac3857a3d0..581625d808 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/IssueBackgroundJobs.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/IssueBackgroundJobs.scala @@ -406,7 +406,10 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { walletAccessContext <- buildWalletAccessContextLayer(issue.from) result <- (for { credentialService <- ZIO.service[CredentialService] - _ <- credentialService.generateJWTCredential(id).provideSomeLayer(ZLayer.succeed(walletAccessContext)) + config <- ZIO.service[AppConfig] + _ <- credentialService + .generateJWTCredential(id, config.pollux.statusListRegistry.publicEndpointUrl) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) } yield ()).mapError(e => (walletAccessContext, e)) } yield result 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 87c39794a0..1a430c8769 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 @@ -32,8 +32,15 @@ import zio.json.ast.Json import zio.metrics.* import zio.prelude.Validation 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 = { @@ -517,7 +524,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( @@ -533,17 +540,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 new file mode 100644 index 0000000000..f5b090fbc7 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/jobs/StatusListJobs.scala @@ -0,0 +1,123 @@ +package io.iohk.atala.agent.server.jobs + +import io.iohk.atala.agent.server.config.AppConfig +import io.iohk.atala.castor.core.model.did.VerificationRelationship +import io.iohk.atala.mercury.* +import io.iohk.atala.mercury.protocol.revocationnotificaiton.RevocationNotification +import io.iohk.atala.pollux.core.service.{CredentialService, CredentialStatusListService} +import io.iohk.atala.pollux.vc.jwt.revocation.{VCStatusList2021, VCStatusList2021Error} +import io.iohk.atala.shared.models.WalletAccessContext +import zio.* +import zio.metrics.Metric +import io.iohk.atala.shared.utils.DurationOps.toMetricsSeconds + +object StatusListJobs extends BackgroundJobsHelper { + + val syncRevocationStatuses = + for { + credentialStatusListService <- ZIO.service[CredentialStatusListService] + credentialService <- ZIO.service[CredentialService] + credentialStatusListsWithCreds <- credentialStatusListService.getCredentialsAndItsStatuses + .mapError(_.toThrowable) @@ Metric + .gauge("revocation_status_list_sync_get_status_lists_w_creds_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + + updatedVcStatusListsCredsEffects = credentialStatusListsWithCreds.map { statusListWithCreds => + val vcStatusListCredString = statusListWithCreds.statusListCredential + val walletAccessContext = WalletAccessContext(statusListWithCreds.walletId) + + val effect = for { + vcStatusListCredJson <- ZIO + .fromEither(io.circe.parser.parse(vcStatusListCredString)) + .mapError(_.underlying) + issuer <- createJwtIssuer(statusListWithCreds.issuer, VerificationRelationship.AssertionMethod) + vcStatusListCred <- VCStatusList2021 + .decodeFromJson(vcStatusListCredJson, issuer) + .mapError(x => new Throwable(x.msg)) + bitString <- vcStatusListCred.getBitString.mapError(x => new Throwable(x.msg)) + updateBitStringEffects = statusListWithCreds.credentials.map { cred => + if cred.isCanceled then { + val sendMessageEffect = for { + maybeIssueCredentialRecord <- credentialService + .getIssueCredentialRecord(cred.issueCredentialRecordId) + .mapError(_.toThrowable) + issueCredentialRecord <- ZIO + .fromOption(maybeIssueCredentialRecord) + .mapError(_ => + new Throwable(s"Issue credential record not found by id: ${cred.issueCredentialRecordId}") + ) + issueCredentialData <- ZIO + .fromOption(issueCredentialRecord.issueCredentialData) + .mapError(_ => + new Throwable( + s"Issue credential data not found in issue credential record by id: ${cred.issueCredentialRecordId}" + ) + ) + issueCredentialProtocolThreadId <- ZIO + .fromOption(issueCredentialData.thid) + .mapError(_ => new Throwable("thid not found in issue credential data")) + revocationNotification = RevocationNotification.build( + issueCredentialData.from, + issueCredentialData.to, + issueCredentialProtocolThreadId = issueCredentialProtocolThreadId + ) + didCommAgent <- buildDIDCommAgent(issueCredentialData.from) + response <- MessagingService + .send(revocationNotification.makeMessage) + .provideSomeLayer(didCommAgent) @@ Metric + .gauge("revocation_status_list_sync_revocation_notification_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } yield response + + val updateBitStringEffect = bitString.setRevokedInPlace(cred.statusListIndex, true) + + val updateAndNotify = for { + updated <- updateBitStringEffect.mapError(x => new Throwable(x.message)) + _ <- + if !cred.isProcessed then + sendMessageEffect.flatMap { resp => + if (resp.status >= 200 && resp.status < 300) + ZIO.logInfo("successfully sent revocation notification message") + else ZIO.logError(s"failed to send revocation notification message") + } + else ZIO.unit + } yield updated + updateAndNotify.provideSomeLayer(ZLayer.succeed(walletAccessContext)) @@ Metric + .gauge("revocation_status_list_sync_process_single_credential_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + } else ZIO.unit + } + _ <- ZIO + .collectAll(updateBitStringEffects) + + unprocessedEntityIds = statusListWithCreds.credentials.collect { + case x if !x.isProcessed && x.isCanceled => x.id + } + _ <- credentialStatusListService + .markAsProcessedMany(unprocessedEntityIds) + .mapError(_.toThrowable) @@ Metric + .gauge("revocation_status_list_sync_mark_as_processed_many_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + + updatedVcStatusListCred <- vcStatusListCred.updateBitString(bitString).mapError { + case VCStatusList2021Error.EncodingError(msg: String) => new Throwable(msg) + case VCStatusList2021Error.DecodingError(msg: String) => new Throwable(msg) + } + vcStatusListCredJsonString <- updatedVcStatusListCred.toJsonWithEmbeddedProof + .map(_.spaces2) + _ <- credentialStatusListService + .updateStatusListCredential(statusListWithCreds.id, vcStatusListCredJsonString) + .mapError(_.toThrowable) + } yield () + + effect.provideSomeLayer(ZLayer.succeed(walletAccessContext)) + + } + config <- ZIO.service[AppConfig] + _ <- (ZIO + .collectAll(updatedVcStatusListsCredsEffects) @@ Metric + .gauge("revocation_status_list_sync_process_status_lists_w_creds_ms_gauge") + .trackDurationWith(_.toMetricsSeconds)) + .withParallelism(config.pollux.syncRevocationStatusesBgJobProcessingParallelism) + } yield () +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/EndpointOutputs.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/EndpointOutputs.scala index 0e695520d2..ae91b269d6 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/EndpointOutputs.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/EndpointOutputs.scala @@ -1,4 +1,5 @@ package io.iohk.atala.api.http + import sttp.model.StatusCode import sttp.tapir.json.zio.jsonBody import sttp.tapir.{oneOfVariantValueMatcher, *} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/codec/DidCommIDCodec.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/codec/DidCommIDCodec.scala new file mode 100644 index 0000000000..171e3ce01f --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/codec/DidCommIDCodec.scala @@ -0,0 +1,19 @@ +package io.iohk.atala.api.http.codec + +import sttp.tapir._ +import sttp.tapir.Codec.PlainCodec +import io.iohk.atala.pollux.core.model.DidCommID +import sttp.tapir.DecodeResult.* + +object DidCommIDCodec { + given didCommIDCodec: PlainCodec[DidCommID] = + Codec.string.mapDecode { s => + if s.nonEmpty && s.length < 64 then Value(DidCommID(s)) + else + Error( + "DidComId must be less then 64 characters long", + new Throwable("DidComId must be less then 64 characters long") + ) + }(didCommID => didCommID.value) + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusController.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusController.scala new file mode 100644 index 0000000000..7091920486 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusController.scala @@ -0,0 +1,37 @@ +package io.iohk.atala.credentialstatus.controller + +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.credentialstatus.controller.http.StatusListCredential +import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError +import io.iohk.atala.shared.models.WalletAccessContext +import zio.* +import io.iohk.atala.pollux.core.model.DidCommID + +import java.util.UUID + +trait CredentialStatusController { + def getStatusListCredentialById(id: UUID)(implicit + rc: RequestContext + ): IO[ErrorResponse, StatusListCredential] + + def revokeCredentialById(id: DidCommID)(implicit + rc: RequestContext + ): ZIO[WalletAccessContext, ErrorResponse, Unit] + +} + +object CredentialStatusController { + def toHttpError(error: CredentialStatusListServiceError): ErrorResponse = + error match + case CredentialStatusListServiceError.RepositoryError(cause) => + ErrorResponse.internalServerError(title = "RepositoryError", detail = Some(cause.toString)) + case CredentialStatusListServiceError.JsonCredentialParsingError(cause) => + ErrorResponse.internalServerError(title = "JsonCredentialParsingError", detail = Some(cause.toString)) + case CredentialStatusListServiceError.RecordIdNotFound(recordId) => + ErrorResponse.notFound(detail = Some(s"Credential status list could not be found by id: $recordId")) + case CredentialStatusListServiceError.IssueCredentialRecordNotFound(issueCredentialRecordId: DidCommID) => + ErrorResponse.notFound(detail = + Some(s"Credential with id $issueCredentialRecordId is either already revoked or does not exist") + ) + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusControllerImpl.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusControllerImpl.scala new file mode 100644 index 0000000000..c4640f0efa --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusControllerImpl.scala @@ -0,0 +1,39 @@ +package io.iohk.atala.credentialstatus.controller + +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.credentialstatus.controller.http.StatusListCredential +import io.iohk.atala.pollux.core.service.CredentialStatusListService +import zio.* +import io.iohk.atala.pollux.core.model.DidCommID +import io.iohk.atala.shared.models.WalletAccessContext + +import java.util.UUID + +class CredentialStatusControllerImpl( + credentialStatusListService: CredentialStatusListService +) extends CredentialStatusController { + def getStatusListCredentialById(id: UUID)(implicit + rc: RequestContext + ): IO[ErrorResponse, StatusListCredential] = { + + credentialStatusListService + .findById(id) + .flatMap(StatusListCredential.fromCredentialStatusListEntry) + .mapError(CredentialStatusController.toHttpError) + + } + + def revokeCredentialById(id: DidCommID)(implicit + rc: RequestContext + ): ZIO[WalletAccessContext, ErrorResponse, Unit] = { + credentialStatusListService + .revokeByIssueCredentialRecordId(id) + .mapError(CredentialStatusController.toHttpError) + } + +} + +object CredentialStatusControllerImpl { + val layer: URLayer[CredentialStatusListService, CredentialStatusControllerImpl] = + ZLayer.fromFunction(CredentialStatusControllerImpl(_)) +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusEndpoints.scala new file mode 100644 index 0000000000..675e5ca169 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusEndpoints.scala @@ -0,0 +1,58 @@ +package io.iohk.atala.credentialstatus.controller + +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.api.http.EndpointOutputs.* +import io.iohk.atala.credentialstatus.controller.http.StatusListCredential +import sttp.tapir.* +import sttp.tapir.json.zio.jsonBody +import io.iohk.atala.iam.authentication.apikey.ApiKeyEndpointSecurityLogic.apiKeyHeader +import io.iohk.atala.iam.authentication.oidc.JwtSecurityLogic.jwtAuthHeader +import java.util.UUID +import io.iohk.atala.pollux.core.model.DidCommID +import io.iohk.atala.api.http.codec.DidCommIDCodec.given +import io.iohk.atala.iam.authentication.apikey.ApiKeyCredentials +import io.iohk.atala.iam.authentication.oidc.JwtCredentials +object CredentialStatusEndpoints { + + val getCredentialStatusListEndpoint: PublicEndpoint[ + (RequestContext, UUID), + ErrorResponse, + StatusListCredential, + Any + ] = + endpoint.get + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in( + "credential-status" / path[UUID]("id").description( + "Globally unique identifier of the credential status list" + ) + ) + .out(jsonBody[StatusListCredential].description("Status List credential with embedded proof found by ID")) + .errorOut(basicFailuresAndNotFound) + .name("getCredentialStatusListEndpoint") + .summary("Fetch credential status list by its ID") + .description( + "Fetch credential status list by its ID" + ) + .tag("Credential status list") + + val revokeCredentialByIdEndpoint: Endpoint[ + (ApiKeyCredentials, JwtCredentials), + (RequestContext, DidCommID), + ErrorResponse, + Unit, + Any + ] = + endpoint.patch + .securityIn(apiKeyHeader) + .securityIn(jwtAuthHeader) + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in( + "credential-status" / "revoke-credential" / path[DidCommID]("id").description("Revoke a credential by its ID") + ) + .out(statusCode(sttp.model.StatusCode.Ok)) + .errorOut(basicFailuresAndNotFound) + .summary("Revoke a credential by its ID") + .description("Marks credential to be ready for revocation, it will be revoked automatically") + .tag("Credential status list") +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusServiceEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusServiceEndpoints.scala new file mode 100644 index 0000000000..5736175b44 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/CredentialStatusServiceEndpoints.scala @@ -0,0 +1,68 @@ +package io.iohk.atala.credentialstatus.controller + +import io.iohk.atala.iam.authentication.{Authenticator, Authorizer} +import io.iohk.atala.agent.walletapi.model.BaseEntity +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.iam.authentication.Authenticator +import io.iohk.atala.iam.authentication.Authorizer +import io.iohk.atala.iam.authentication.DefaultAuthenticator +import io.iohk.atala.iam.authentication.SecurityLogic +import io.iohk.atala.shared.models.WalletAccessContext +import sttp.tapir.ztapir.* +import zio.* +import io.iohk.atala.credentialstatus.controller.CredentialStatusEndpoints.* +import sttp.model.StatusCode +import io.iohk.atala.pollux.core.model.DidCommID + +import java.util.UUID + +class CredentialStatusServiceEndpoints( + credentialStatusController: CredentialStatusController, + authenticator: Authenticator[BaseEntity], + authorizer: Authorizer[BaseEntity] +) { + + private def obfuscateInternalServerError(e: ErrorResponse): ErrorResponse = + if e.status == StatusCode.InternalServerError.code then e.copy(detail = Some("Something went wrong")) + else e + + private val getCredentialStatusListById: ZServerEndpoint[Any, Any] = + getCredentialStatusListEndpoint + .zServerLogic { case (ctx: RequestContext, id: UUID) => + credentialStatusController + .getStatusListCredentialById(id)(ctx) + .logError + .mapError(obfuscateInternalServerError) + + } + + private val revokeCredentialById: ZServerEndpoint[Any, Any] = { + revokeCredentialByIdEndpoint + .zServerSecurityLogic(SecurityLogic.authorizeWalletAccessWith(_)(authenticator, authorizer)) + .serverLogic { wac => + { case (ctx: RequestContext, id: DidCommID) => + credentialStatusController + .revokeCredentialById(id)(ctx) + .logError + .mapError(obfuscateInternalServerError) + .provideSomeLayer(ZLayer.succeed(wac)) + } + + } + } + + val all: List[ZServerEndpoint[Any, Any]] = List( + getCredentialStatusListById, + revokeCredentialById + ) +} + +object CredentialStatusServiceEndpoints { + def all: URIO[CredentialStatusController & DefaultAuthenticator, List[ZServerEndpoint[Any, Any]]] = { + for { + authenticator <- ZIO.service[DefaultAuthenticator] + statusListController <- ZIO.service[CredentialStatusController] + statusLisEndpoints = new CredentialStatusServiceEndpoints(statusListController, authenticator, authenticator) + } yield statusLisEndpoints.all + } +} 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 new file mode 100644 index 0000000000..18cbdc78a8 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/credentialstatus/controller/http/StatusListCredential.scala @@ -0,0 +1,179 @@ +package io.iohk.atala.credentialstatus.controller.http + +import io.iohk.atala.pollux.vc.jwt.StatusPurpose +import io.iohk.atala.api.http.Annotation +import sttp.tapir.Schema.annotations.{description, encodedExample} +import io.iohk.atala.credentialstatus.controller.http.StatusListCredential.annotations +import sttp.tapir.Schema +import zio.json.* +import io.iohk.atala.pollux.core.model.CredentialStatusList +import io.iohk.atala.pollux.core.model.error.CredentialStatusListServiceError +import sttp.tapir.json.zio.schemaForZioJsonValue +import zio.json.ast.Json +import zio.* +import java.time.Instant + +case class StatusListCredential( + @description(annotations.`@context`.description) + @encodedExample(annotations.`@context`.example) + `@context`: Set[String], + @description(annotations.`type`.description) + @encodedExample(annotations.`type`.example) + `type`: Set[String], + @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, + @description("Object containing claims specific to status list credential") + credentialSubject: CredentialSubject, + @description(annotations.proof.description) + @encodedExample(annotations.proof.example) + proof: Json +) + +case class CredentialSubject( + @description(annotations.credentialSubject.id.description) + @encodedExample(annotations.credentialSubject.id.example) + id: String, + @description(annotations.credentialSubject.`type`.description) + @encodedExample(annotations.credentialSubject.`type`.example) + `type`: String, + @description(annotations.credentialSubject.statusPurpose.description) + @encodedExample(annotations.credentialSubject.statusPurpose.example) + statusPurpose: StatusPurpose, + @description(annotations.credentialSubject.encodedList.description) + @encodedExample(annotations.credentialSubject.encodedList.example) + encodedList: String +) + +object StatusListCredential { + + def fromCredentialStatusListEntry( + domain: CredentialStatusList + ): IO[CredentialStatusListServiceError, StatusListCredential] = { + + val res = ZIO + .fromEither(domain.statusListCredential.fromJson[StatusListCredential]) + .mapError(err => CredentialStatusListServiceError.JsonCredentialParsingError(new Throwable(err))) + + res + } + + object annotations { + object `@context` + extends Annotation[Set[String]]( + description = "List of JSON-LD contexts", + example = Set("https://www.w3.org/2018/credentials/v1", "https://w3id.org/vc/status-list/2021/v1") + ) + + object `type` + extends Annotation[Set[String]]( + description = "List of credential types", + example = Set("VerifiableCredential", "StatusList2021Credential") + ) + + object issuer + extends Annotation[String]( + description = "DID of the issuer of status list credential", + 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", + example = Instant.now() + ) + + object credentialSubject { + object id + extends Annotation[String]( + description = "Url to resolve this particular status list credential", + example = "http://issuer-agent.com/credential-status/060a2bec-6d6f-4c1f-9414-d3c9dbd3ccc9" + ) + + object `type` + extends Annotation[String]( + description = "Always equals to constnat value - StatusList2021", + example = "StatusList2021" + ) + + object statusPurpose + extends Annotation[StatusPurpose]( + description = "type of status list credential, either revocation or suspension", + example = StatusPurpose.Revocation + ) + + object encodedList + extends Annotation[String]( + description = "base64 url encoded bitstring of credential statuses", + example = "H4sIAAAAAAAA_-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" + ) + + } + + object proof + extends Annotation[Json]( + description = + """Embedded proof to verify data integrity of status list credential, includes "type" property which defines an algorithm to be used for proof verification""", + example = proofJsonExample.fromJson[Json].toOption.getOrElse(Json.Null) + ) + + } + + val proofJsonExample: String = + """ + |{ + | "type": "DataIntegrityProof", + | "proofPurpose": "assertionMethod", + | "verificationMethod": "data:application/json;base64,eyJAY29udGV4dCI6WyJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L211bHRpa2V5L3YxIl0sInR5cGUiOiJNdWx0aWtleSIsInB1YmxpY0tleU11bHRpYmFzZSI6InVNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVRUENjM1M0X0xHVXRIM25DRjZ2dUw3ekdEMS13UmVrMHRHbnB0UnZUakhIMUdvTnk1UFBIZ0FmNTZlSzNOd3B0LWNGcmhrT2pRQk1rcFRKOHNaS1pCZz09In0=", + | "created": "2024-01-22T22:40:34.560891Z", + | "proofValue": "zAN1rKq8npnByRqPRxhjHEkivhN8AhA8V6MqDJga1zcCUEvPDUoqJB5Rj6ZJHTCnBZ98VXTEVd1rprX2wvP1MAaTEi7Pm241qm", + | "cryptoSuite": "eddsa-jcs-2022" + |} + |""".stripMargin + + given StatusPurposeCodec: JsonCodec[StatusPurpose] = JsonCodec[StatusPurpose]( + JsonEncoder[String].contramap[StatusPurpose](_.str), + JsonDecoder[String].mapOrFail { + case StatusPurpose.Revocation.str => Right(StatusPurpose.Revocation) + case StatusPurpose.Suspension.str => Right(StatusPurpose.Suspension) + case str => Left(s"no enum value matched for \"$str\"") + }, + ) + + given instantDecoder: JsonDecoder[Instant] = + JsonDecoder[Long].map(Instant.ofEpochSecond) + + given instantEncoder: JsonEncoder[Instant] = + JsonEncoder[Long].contramap(_.getEpochSecond) + + given statusListCredentialEncoder: JsonEncoder[StatusListCredential] = + DeriveJsonEncoder.gen[StatusListCredential] + + given statusListCredentialDecoder: JsonDecoder[StatusListCredential] = + DeriveJsonDecoder.gen[StatusListCredential] + + given credentialSubjectEncoder: JsonEncoder[CredentialSubject] = + DeriveJsonEncoder.gen[CredentialSubject] + + given credentialSubjectDecoder: JsonDecoder[CredentialSubject] = + DeriveJsonDecoder.gen[CredentialSubject] + + given credentialSubjectSchema: Schema[CredentialSubject] = Schema.derived + + given statusPurposeSchema: Schema[StatusPurpose] = Schema.derived + + given statusListCredentialSchema: Schema[StatusListCredential] = Schema.derived + +} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala index a6086288d9..73593dfd12 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala @@ -4,6 +4,7 @@ import io.iohk.atala.agent.server.AgentHttpServer import io.iohk.atala.agent.server.http.DocModels import io.iohk.atala.castor.controller.{DIDController, DIDRegistrarController} import io.iohk.atala.connect.controller.ConnectionController +import io.iohk.atala.credentialstatus.controller.CredentialStatusController import io.iohk.atala.event.controller.EventController import io.iohk.atala.iam.authentication.DefaultAuthenticator import io.iohk.atala.iam.entity.http.controller.EntityController @@ -39,6 +40,7 @@ object Tapir2StaticOAS extends ZIOAppDefault { ZLayer.succeed(mock[ConnectionController]) ++ ZLayer.succeed(mock[CredentialDefinitionController]) ++ ZLayer.succeed(mock[CredentialSchemaController]) ++ + ZLayer.succeed(mock[CredentialStatusController]) ++ ZLayer.succeed(mock[VerificationPolicyController]) ++ ZLayer.succeed(mock[DIDRegistrarController]) ++ ZLayer.succeed(mock[PresentProofController]) ++ diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala index 880e697c89..655b42bf7b 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala @@ -96,7 +96,10 @@ object IssueControllerSpec extends ZIOSpecDefault { maybeCredentialStatus = Some( CredentialStatus( id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", - `type` = "CredentialStatusList2017" + `type` = "StatusList2021Entry", + statusPurpose = StatusPurpose.Revocation, + statusListIndex = 0, + statusListCredential = "https://example.com/credentials/status/3" ) ), maybeRefreshService = Some( diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala index b5fd42f818..aee7716562 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala @@ -18,7 +18,11 @@ import io.iohk.atala.issue.controller.http.{ } import io.iohk.atala.pollux.anoncreds.AnoncredLinkSecretWithId import io.iohk.atala.pollux.core.model.CredentialFormat -import io.iohk.atala.pollux.core.repository.{CredentialDefinitionRepositoryInMemory, CredentialRepositoryInMemory} +import io.iohk.atala.pollux.core.repository.{ + CredentialDefinitionRepositoryInMemory, + CredentialRepositoryInMemory, + CredentialStatusListRepositoryInMemory +} import io.iohk.atala.pollux.core.service.* import io.iohk.atala.pollux.vc.jwt.* import io.iohk.atala.shared.models.{WalletAccessContext, WalletId} @@ -35,7 +39,6 @@ import zio.config.{ReadError, read} import zio.json.ast.Json import zio.json.ast.Json.* import zio.test.* - import java.util.UUID trait IssueControllerTestTools extends PostgresTestContainerSupport { @@ -81,6 +84,7 @@ trait IssueControllerTestTools extends PostgresTestContainerSupport { didResolverLayer >+> ResourceURIDereferencerImpl.layer >+> CredentialRepositoryInMemory.layer >+> + CredentialStatusListRepositoryInMemory.layer >+> ZLayer.succeed(AnoncredLinkSecretWithId("Unused Linked Secret ID")) >+> MockDIDService.empty >+> MockManagedDIDService.empty >+> 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/shared/src/main/scala/io/iohk/atala/shared/models/MultiTenancy.scala b/shared/src/main/scala/io/iohk/atala/shared/models/MultiTenancy.scala index bafe9cdeee..87f2d87995 100644 --- a/shared/src/main/scala/io/iohk/atala/shared/models/MultiTenancy.scala +++ b/shared/src/main/scala/io/iohk/atala/shared/models/MultiTenancy.scala @@ -7,6 +7,8 @@ opaque type WalletId = UUID object WalletId { def fromUUID(uuid: UUID): WalletId = uuid + + def fromUUIDString(uuidStr: String): WalletId = UUID.fromString(uuidStr) def random: WalletId = fromUUID(UUID.randomUUID()) def default: WalletId = fromUUID(UUID.fromString("00000000-0000-0000-0000-000000000000")) diff --git a/shared/src/main/scala/io/iohk/atala/shared/utils/Base64Utils.scala b/shared/src/main/scala/io/iohk/atala/shared/utils/Base64Utils.scala index de3c217d4d..bc1449736b 100644 --- a/shared/src/main/scala/io/iohk/atala/shared/utils/Base64Utils.scala +++ b/shared/src/main/scala/io/iohk/atala/shared/utils/Base64Utils.scala @@ -15,4 +15,9 @@ object Base64Utils { def decodeURL(string: String): Array[Byte] = { Base64.getUrlDecoder.decode(string) } + + def createDataUrl(data: Array[Byte], mimeType: String): String = { + val encodedData = encodeURL(data) + s"data:$mimeType;base64,$encodedData" + } } diff --git a/shared/src/main/scala/io/iohk/atala/shared/utils/Json.scala b/shared/src/main/scala/io/iohk/atala/shared/utils/Json.scala new file mode 100644 index 0000000000..2607ddf997 --- /dev/null +++ b/shared/src/main/scala/io/iohk/atala/shared/utils/Json.scala @@ -0,0 +1,20 @@ +package io.iohk.atala.shared.utils + +import org.erdtman.jcs.JsonCanonicalizer +import scala.util.Try + +object Json { + + /** Canonicalizes a JSON string to JCS format according to RFC 8785 + * + * @param jsonStr + * JSON string to canonicalize + * @return + * canonicalized JSON string + */ + + def canonicalizeToJcs(jsonStr: String): Either[Throwable, String] = { + val canonicalizer = Try { new JsonCanonicalizer(jsonStr) } + canonicalizer.map(_.getEncodedString).toEither + } +} diff --git a/tests/integration-tests/src/test/kotlin/config/services/Agent.kt b/tests/integration-tests/src/test/kotlin/config/services/Agent.kt index 1c25fb0efb..0e4c4c8ca3 100644 --- a/tests/integration-tests/src/test/kotlin/config/services/Agent.kt +++ b/tests/integration-tests/src/test/kotlin/config/services/Agent.kt @@ -16,7 +16,7 @@ data class Agent( @ConfigAlias("prism_node") val prismNode: PrismNode?, val keycloak: Keycloak?, val vault: Vault?, - @ConfigAlias("keep_running") override val keepRunning: Boolean = false + @ConfigAlias("keep_running") override val keepRunning: Boolean = false, ) : ServiceBase { override val container: ComposeContainer @@ -37,6 +37,7 @@ data class Agent( "KEYCLOAK_REALM" to (keycloak?.realm ?: ""), "KEYCLOAK_CLIENT_ID" to (keycloak?.clientId ?: ""), "KEYCLOAK_CLIENT_SECRET" to (keycloak?.clientSecret ?: ""), + "POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL" to "http://host.docker.internal:$httpPort", ) // setup token authentication @@ -48,7 +49,8 @@ data class Agent( } container = ComposeContainer( - File("src/test/resources/containers/agent.yml")) + File("src/test/resources/containers/agent.yml"), + ) .withEnv(env) .waitingFor("open-enterprise-agent", Wait.forHealthcheck()) } diff --git a/tests/integration-tests/src/test/resources/containers/agent.yml b/tests/integration-tests/src/test/resources/containers/agent.yml index 033601a9d1..da6905dbb7 100644 --- a/tests/integration-tests/src/test/resources/containers/agent.yml +++ b/tests/integration-tests/src/test/resources/containers/agent.yml @@ -2,7 +2,6 @@ version: "3.8" services: - # Mandatory PostgreSQL database for the Open Enterprise Agent postgres: image: postgres:13 @@ -41,6 +40,7 @@ services: AGENT_HTTP_PORT: DIDCOMM_SERVICE_URL: REST_SERVICE_URL: + POLLUX_STATUS_LIST_REGISTRY_PUBLIC_URL: API_KEY_ENABLED: # Secret storage configuration SECRET_STORAGE_BACKEND: @@ -59,7 +59,13 @@ services: - "${AGENT_DIDCOMM_PORT}:${AGENT_DIDCOMM_PORT}" - "${AGENT_HTTP_PORT}:${AGENT_HTTP_PORT}" healthcheck: - test: ["CMD", "curl", "-f", "http://open-enterprise-agent:${AGENT_HTTP_PORT}/_system/health"] + test: + [ + "CMD", + "curl", + "-f", + "http://open-enterprise-agent:${AGENT_HTTP_PORT}/_system/health", + ] interval: 10s timeout: 5s retries: 5 diff --git a/tests/performance-tests/atala-performance-tests-k6/.env b/tests/performance-tests/atala-performance-tests-k6/.env new file mode 100644 index 0000000000..4b8958681d --- /dev/null +++ b/tests/performance-tests/atala-performance-tests-k6/.env @@ -0,0 +1,3 @@ +PRISM_AGENT_VERSION=1.30.1-SNAPSHOT +PRISM_NODE_VERSION=2.2.1 +VAULT_DEV_ROOT_TOKEN_ID=root 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..c95ba88c2d 100644 --- a/tests/performance-tests/atala-performance-tests-k6/README.md +++ b/tests/performance-tests/atala-performance-tests-k6/README.md @@ -10,8 +10,11 @@ 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 +yarn install ``` # Running the test @@ -19,7 +22,7 @@ $ yarn install To run a test written in TypeScript, we first have to transpile the TypeScript code into JavaScript and bundle the project ```bash -$ yarn webpack +yarn webpack ``` This command creates the final test files to the `./dist` folder. @@ -27,10 +30,10 @@ This command creates the final test files to the `./dist` folder. Once that is done, we can run our script the same way we usually do, for instance: ```bash -$ k6 run dist/connection-flow-test.js +k6 run dist/connection-flow-test.js ``` -# Debugging Tests +## Debugging Tests k6 can be configured to log the HTTP request and responses that it makes during test execution. This is useful to debug errors that happen in tests when logs or k6 output does not contain the reason for a failure. 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", diff --git a/tests/performance-tests/atala-performance-tests-k6/run.sh b/tests/performance-tests/atala-performance-tests-k6/run.sh new file mode 100755 index 0000000000..e4b4198482 --- /dev/null +++ b/tests/performance-tests/atala-performance-tests-k6/run.sh @@ -0,0 +1,176 @@ +#!/bin/bash + +set -e + +# Variables +ENV_FILE=".env" +PERF_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +AGENT_DIR="$PERF_DIR/../../.." +DOCKERFILE="$AGENT_DIR/infrastructure/shared/docker-compose.yml" +K6_URL="https://github.com/grafana/k6/releases/download/v0.45.0/k6-v0.45.0-macos-arm64.zip" +K6_ZIP_FILE="$(basename ${K6_URL})" + +# Functions +function startAgent() { + echo "Starting [$NAME] agent" + PORT="${PORT}" \ + ADMIN_TOKEN="${ADMIN_TOKEN}" \ + DEFAULT_WALLET_ENABLED="${DEFAULT_WALLET_ENABLED}" \ + DEFAULT_WALLET_AUTH_API_KEY="${DEFAULT_WALLET_AUTH_API_KEY}" \ + API_KEY_AUTO_PROVISIONING="${API_KEY_AUTO_PROVISIONING}" \ + API_KEY_ENABLED="${API_KEY_ENABLED}" \ + DOCKERHOST="${DOCKERHOST}" \ + PG_PORT="${PG_PORT}" \ + NODE_REFRESH_AND_SUBMIT_PERIOD="${NODE_REFRESH_AND_SUBMIT_PERIOD}" \ + NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD="${NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD}" \ + NODE_WALLET_MAX_TPS="${NODE_WALLET_MAX_TPS}" \ + docker compose -p "${NAME}" -f "${DOCKERFILE}" \ + --env-file "${ENV_FILE}" up -d --wait 2>/dev/null + echo "Agent [$NAME] healthy" +} + +function stopAgent() { + echo "Stopping [${NAME}] agent" + PORT="${PORT}" \ + DOCKERHOST="${DOCKERHOST}" \ + docker compose \ + -p "${NAME}" \ + -f "${DOCKERFILE}" \ + --env-file "${ENV_FILE}" down -v 2>/dev/null + echo "Agent [${NAME}] stopped" +} + +function createIssuer() { + local NAME="issuer" + local PORT=8080 + local ADMIN_TOKEN=admin + local DEFAULT_WALLET_ENABLED=true + local DEFAULT_WALLET_AUTH_API_KEY=default + local API_KEY_AUTO_PROVISIONING=false + local API_KEY_ENABLED=true + local DOCKERHOST="host.docker.internal" + local PG_PORT=5432 + local NODE_REFRESH_AND_SUBMIT_PERIOD="1s" + local NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD="1s" + local NODE_WALLET_MAX_TPS="1000" + + startAgent +} + +function createHolder() { + local NAME="holder" + local PORT=8090 + local ADMIN_TOKEN=admin + local DEFAULT_WALLET_ENABLED=true + local DEFAULT_WALLET_AUTH_API_KEY=default + local API_KEY_AUTO_PROVISIONING=false + local API_KEY_ENABLED=true + local DOCKERHOST="host.docker.internal" + local PG_PORT=5433 + local NODE_REFRESH_AND_SUBMIT_PERIOD="1s" + local NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD="1s" + local NODE_WALLET_MAX_TPS="1000" + + startAgent +} + +function createVerifier() { + local NAME="verifier" + local PORT=8100 + local ADMIN_TOKEN=admin + local DEFAULT_WALLET_ENABLED=true + local DEFAULT_WALLET_AUTH_API_KEY=default + local API_KEY_AUTO_PROVISIONING=false + local API_KEY_ENABLED=true + local DOCKERHOST="host.docker.internal" + local PG_PORT=5434 + local NODE_REFRESH_AND_SUBMIT_PERIOD="1s" + local NODE_MOVE_SCHEDULED_TO_PENDING_PERIOD="1s" + local NODE_WALLET_MAX_TPS="1000" + + startAgent +} + +function removeIssuer() { + local NAME="issuer" + local PORT=8080 + local DOCKERHOST="host.docker.internal" + + stopAgent +} + +function removeVerifier() { + local NAME="verifier" + local PORT=8100 + local DOCKERHOST="host.docker.internal" + + stopAgent +} + +function removeHolder() { + local NAME="holder" + local PORT=8090 + local DOCKERHOST="host.docker.internal" + + stopAgent +} + +# clean up on finish +function cleanup() { + echo "Removing K6 binaries" + rm k6 + rm "$K6_ZIP_FILE" + + removeIssuer & + removeVerifier & + removeHolder & + wait +} + +trap 'cleanup' EXIT + +# download and unzip k6 +echo "Downloading K6" +curl -LO -s "${K6_URL}" +unzip -j "${K6_ZIP_FILE}" >/dev/null +echo "K6 downloaded" + +## navigate to main project +cd "$AGENT_DIR" + +##sbt docker:publishLocal +PRISM_AGENT_VERSION=$(cut -d '"' -f 2 version.sbt) + +## back to performance folder +cd "$PERF_DIR" + +# set version to env file +sed -i.bak "s/PRISM_AGENT_VERSION=.*/PRISM_AGENT_VERSION=${PRISM_AGENT_VERSION}/" "${ENV_FILE}" && rm -f "${ENV_FILE}.bak" + +# create agents in parallel +createIssuer & +createHolder & +createVerifier & +wait + +# yarn install +echo "Installing dependencies" +yarn -s >/dev/null +echo "Building performance tests" +yarn webpack >/dev/null + +# start perf test +echo "Starting performance testing" + +export ISSUER_AGENT_API_KEY=default +export HOLDER_AGENT_API_KEY=default +export VERIFIER_AGENT_API_KEY=default + +./k6 run -e SCENARIO_LABEL=create-prism-did-smoke ./dist/create-prism-did-test.js +./k6 run -e SCENARIO_LABEL=credential-offer-smoke ./dist/credential-offer-test.js +./k6 run -e SCENARIO_LABEL=credential-definition-smoke ./dist/credential-definition-test.js +./k6 run -e SCENARIO_LABEL=credential-schema-smoke ./dist/credential-schema-test.js +./k6 run -e SCENARIO_LABEL=did-publishing-smoke ./dist/did-publishing-test.js +./k6 run -e SCENARIO_LABEL=connection-flow-smoke ./dist/connection-flow-test.js +./k6 run -e SCENARIO_LABEL=issuance-flow-smoke ./dist/issuance-flow-test.js +./k6 run -e SCENARIO_LABEL=present-proof-flow-smoke ./dist/present-proof-flow-test.js diff --git a/tests/performance-tests/atala-performance-tests-k6/src/common/CredentialsService.ts b/tests/performance-tests/atala-performance-tests-k6/src/common/CredentialsService.ts index b5e64484e1..da946e66bf 100644 --- a/tests/performance-tests/atala-performance-tests-k6/src/common/CredentialsService.ts +++ b/tests/performance-tests/atala-performance-tests-k6/src/common/CredentialsService.ts @@ -201,7 +201,7 @@ export class CredentialsService extends HttpService { do { // console.log(`Waiting for credential offer with thid=${thid}`) record = this.getCredentialRecords(thid).find( - r => r.thid === thid && r.protocolState === "OfferReceived" + r => r.thid === thid && r.protocolState === "OfferReceived" ); if (record) { return record;