From f5554d6ce8d9b5dc08f5fcafe818e79bfd5a3817 Mon Sep 17 00:00:00 2001 From: Fabio Pinheiro Date: Wed, 29 May 2024 11:36:46 +0200 Subject: [PATCH] feat: integrate SD JWT (#1016) Signed-off-by: FabioPinheiro Signed-off-by: mineme0110 Co-authored-by: mineme0110 --- .github/workflows/integration-tests.yml | 2 +- .github/workflows/performance-tests.yml | 2 +- build.sbt | 20 +- .../server/jobs/BackgroundJobsHelper.scala | 93 ++++- .../server/jobs/IssueBackgroundJobs.scala | 87 ++++ .../server/jobs/PresentBackgroundJobs.scala | 370 ++++++++++++++++-- .../controller/IssueControllerImpl.scala | 49 ++- .../PresentProofControllerImpl.scala | 47 ++- .../http/RequestPresentationAction.scala | 38 +- .../http/RequestPresentationInput.scala | 20 +- .../controller/IssueControllerImplSpec.scala | 15 +- .../issuecredential/IssueFormats.scala | 4 + .../presentproof/PresentFormats.scala | 3 + .../pollux/core/model/CredentialFormat.scala | 2 + .../core/model/IssueCredentialRecord.scala | 3 + .../core/model/PresentationRecord.scala | 3 + .../core/model/error/PresentationError.scala | 4 +- .../SdJwtPresentationPayload.scala | 17 + .../repository/PresentationRepository.scala | 7 + .../PresentationRepositoryInMemory.scala | 31 +- .../core/service/CredentialService.scala | 40 ++ .../core/service/CredentialServiceImpl.scala | 286 +++++++++++++- .../service/CredentialServiceNotifier.scala | 33 ++ .../core/service/MockCredentialService.scala | 49 +++ .../service/MockPresentationService.scala | 45 ++- .../core/service/PresentationService.scala | 29 ++ .../service/PresentationServiceImpl.scala | 235 ++++++++++- .../service/PresentationServiceNotifier.scala | 45 ++- .../serdes/SDJwtPresentationRequest.scala | 14 + .../PresentationRepositorySpecSuite.scala | 2 + .../PresentationServiceNotifierSpec.scala | 2 + .../identus/pollux/sdjwt/CrytoUtils.scala | 23 ++ .../identus/pollux/sdjwt/Models.scala | 122 ++++++ .../pollux/sdjwt/ModelsExtensionMethods.scala | 69 ++++ .../identus/pollux/sdjwt/QueryUtils.scala | 42 ++ .../identus/pollux/sdjwt/SDJWT.scala | 247 ++++++++++++ .../identus/pollux/sdjwt/SDJWTSpec.scala | 292 ++++++++++++++ .../pollux/sdjwt/ValidClaimsSpec.scala | 77 ++++ ...dd_anoncred_credentials_to_use_columns.sql | 4 + .../JdbcPresentationRepository.scala | 49 ++- .../identus/pollux/vc/jwt/DidJWT.scala | 33 +- .../pollux/vc/jwt/JWTVerification.scala | 34 +- .../identus/pollux/vc/jwt/Proof.scala | 156 ++++++-- .../vc/jwt/VerifiableCredentialPayload.scala | 10 +- .../identus/shared/utils/Json.scala | 9 +- .../identus/shared/crypto/Apollo.scala | 42 +- .../identus/shared/crypto/KmpApollo.scala | 3 +- 47 files changed, 2672 insertions(+), 137 deletions(-) create mode 100644 pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/presentation/SdJwtPresentationPayload.scala create mode 100644 pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/serdes/SDJwtPresentationRequest.scala create mode 100644 pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/CrytoUtils.scala create mode 100644 pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/Models.scala create mode 100644 pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/ModelsExtensionMethods.scala create mode 100644 pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/QueryUtils.scala create mode 100644 pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/SDJWT.scala create mode 100644 pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/SDJWTSpec.scala create mode 100644 pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/ValidClaimsSpec.scala create mode 100644 pollux/sql-doobie/src/main/resources/sql/pollux/V20__add_anoncred_credentials_to_use_columns.sql diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2467780df2..48424e4fff 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Java and Scala uses: olafurpg/setup-scala@v14 with: - java-version: openjdk@1.11 + java-version: openjdk@1.17 - name: Setup Gradle uses: gradle/gradle-build-action@v3 diff --git a/.github/workflows/performance-tests.yml b/.github/workflows/performance-tests.yml index c487e92863..39b93decf2 100644 --- a/.github/workflows/performance-tests.yml +++ b/.github/workflows/performance-tests.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Java and Scala uses: olafurpg/setup-scala@v14 with: - java-version: openjdk@1.11 + java-version: openjdk@1.17 - name: Setup Gradle uses: gradle/gradle-build-action@v3 diff --git a/build.sbt b/build.sbt index 28630da539..de2cdf1ecc 100644 --- a/build.sbt +++ b/build.sbt @@ -163,6 +163,7 @@ lazy val D = new { val mockito: ModuleID = "org.scalatestplus" %% "mockito-4-11" % V.mockito % Test val monocle: ModuleID = "dev.optics" %% "monocle-core" % V.monocle % Test val monocleMacro: ModuleID = "dev.optics" %% "monocle-macro" % V.monocle % Test + val scalaTest = "org.scalatest" %% "scalatest" % "3.2.16" % Test val apollo = "io.iohk.atala.prism.apollo" % "apollo-jvm" % V.apollo @@ -315,12 +316,10 @@ lazy val D_Pollux_VC_JWT = new { val zioTestSbt = "dev.zio" %% "zio-test-sbt" % V.zio % Test val zioTestMagnolia = "dev.zio" %% "zio-test-magnolia" % V.zio % Test - val scalaTest = "org.scalatest" %% "scalatest" % "3.2.16" % Test - // Dependency Modules val zioDependencies: Seq[ModuleID] = Seq(zio, zioPrelude, zioTest, zioTestSbt, zioTestMagnolia) val baseDependencies: Seq[ModuleID] = - zioDependencies :+ D.jwtCirce :+ circeJsonSchema :+ networkntJsonSchemaValidator :+ D.nimbusJwt :+ scalaTest + zioDependencies :+ D.jwtCirce :+ circeJsonSchema :+ networkntJsonSchemaValidator :+ D.nimbusJwt :+ D.scalaTest // Project Dependencies lazy val polluxVcJwtDependencies: Seq[ModuleID] = baseDependencies @@ -419,6 +418,7 @@ publish / skip := true val commonSetttings = Seq( testFrameworks ++= Seq(new TestFramework("zio.test.sbt.ZTestFramework")), + libraryDependencies ++= Seq(D.zioTest, D.zioTestSbt, D.zioTestMagnolia), // Needed for Kotlin coroutines that support new memory management mode resolvers += "JetBrains Space Maven Repository" at "https://maven.pkg.jetbrains.space/public/p/kotlinx-coroutines/maven", resolvers += "jitpack" at "https://jitpack.io", @@ -729,7 +729,7 @@ lazy val polluxCore = project .dependsOn(shared) .dependsOn(agentWalletAPI) .dependsOn(polluxVcJWT) - .dependsOn(vc, resolver, agentDidcommx, eventNotification, polluxAnoncreds) + .dependsOn(vc, resolver, agentDidcommx, eventNotification, polluxAnoncreds, polluxSDJWT) lazy val polluxDoobie = project .in(file("pollux/sql-doobie")) @@ -761,9 +761,18 @@ lazy val polluxAnoncreds = project lazy val polluxAnoncredsTest = project .in(file("pollux/anoncredsTest")) - .settings(libraryDependencies ++= Seq("org.scalatest" %% "scalatest" % "3.2.15" % Test)) + .settings(libraryDependencies += D.scalaTest) .dependsOn(polluxAnoncreds % "compile->test") +lazy val polluxSDJWT = project + .in(file("pollux/sd-jwt")) + .settings(commonSetttings) + .settings( + name := "pollux-sd-jwt", + libraryDependencies += "io.iohk.atala" % "sd-jwt-kmp-jvm" % "0.1.2" + ) + .dependsOn(sharedCrypto) + // ##################### // ##### connect ##### // ##################### @@ -905,6 +914,7 @@ lazy val aggregatedProjects: Seq[ProjectReference] = Seq( polluxDoobie, polluxAnoncreds, polluxAnoncredsTest, + polluxSDJWT, connectCore, connectDoobie, agentWalletAPI, diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala index d3e9ba5845..13bed22f39 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/BackgroundJobsHelper.scala @@ -5,14 +5,25 @@ import org.hyperledger.identus.agent.walletapi.model.{ManagedDIDState, Publicati import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService import org.hyperledger.identus.castor.core.model.did.{LongFormPrismDID, PrismDID, VerificationRelationship} import org.hyperledger.identus.castor.core.service.DIDService +import org.hyperledger.identus.pollux.vc.jwt.{ + DIDResolutionFailed, + DIDResolutionSucceeded, + DidResolver as JwtDidResolver +} +import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.{AgentPeerService, DidAgent} -import org.hyperledger.identus.pollux.vc.jwt.{ES256KSigner, Issuer as JwtIssuer} +import org.hyperledger.identus.pollux.vc.jwt.{ES256KSigner, EdSigner, Issuer as JwtIssuer} import org.hyperledger.identus.shared.models.WalletAccessContext import zio.{ZIO, ZLayer} import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError.WalletNotFoundError +import org.hyperledger.identus.shared.crypto.{Ed25519KeyPair, Ed25519PublicKey} +import org.hyperledger.identus.pollux.core.model.error.PresentationError +import org.hyperledger.identus.pollux.sdjwt.SDJWT.* +import java.util.Base64 +import org.hyperledger.identus.shared.crypto.KmpEd25519KeyOps trait BackgroundJobsHelper { def getLongForm( @@ -86,4 +97,84 @@ trait BackgroundJobsHelper { } yield walletAccessContext } + def getEd25519SigningKeyPair( + jwtIssuerDID: PrismDID, + verificationRelationship: VerificationRelationship + ): ZIO[DIDService & ManagedDIDService & WalletAccessContext, Throwable, Ed25519KeyPair] = { + for { + managedDIDService <- ZIO.service[ManagedDIDService] + didService <- ZIO.service[DIDService] + issuingKeyId <- didService + .resolveDID(jwtIssuerDID) + .mapError(e => RuntimeException(s"Error occured while resolving Issuing DID during VC creation: ${e.toString}")) + .someOrFail(RuntimeException(s"Issuing DID resolution result is not found")) + .map { case (_, didData) => didData.publicKeys.find(_.purpose == verificationRelationship).map(_.id) } + .someOrFail( + RuntimeException(s"Issuing DID doesn't have a key in ${verificationRelationship.name} to use: $jwtIssuerDID") + ) + ed25519keyPair <- managedDIDService + .findDIDKeyPair(jwtIssuerDID.asCanonical, issuingKeyId) + .map(_.collect { case keyPair: Ed25519KeyPair => keyPair }) + .mapError(e => RuntimeException(s"Error occurred while getting issuer key-pair: ${e.toString}")) + .someOrFail( + RuntimeException(s"Issuer key-pair does not exist in the wallet: ${jwtIssuerDID.toString}#$issuingKeyId") + ) + } yield ed25519keyPair + } + + /** @param jwtIssuerDID + * This can holder prism did / issuer prism did + * @param verificationRelationship + * Holder it Authentication and Issuer it is AssertionMethod + * @return + * JwtIssuer + * @see + * org.hyperledger.identus.pollux.vc.jwt.Issuer + */ + def getSDJwtIssuer( + jwtIssuerDID: PrismDID, + verificationRelationship: VerificationRelationship + ): ZIO[DIDService & ManagedDIDService & WalletAccessContext, Throwable, JwtIssuer] = { + for { + ed25519keyPair <- getEd25519SigningKeyPair(jwtIssuerDID, verificationRelationship) + } yield { + JwtIssuer( + org.hyperledger.identus.pollux.vc.jwt.DID(jwtIssuerDID.toString), + EdSigner(ed25519keyPair), + Ed25519PublicKey.toJavaEd25519PublicKey(ed25519keyPair.publicKey.getEncoded) + ) + } + } + + def resolveToEd25519PublicKey(did: String): ZIO[JwtDidResolver, PresentationError, Ed25519PublicKey] = { + for { + didResolverService <- ZIO.service[JwtDidResolver] + didResolutionResult <- didResolverService.resolve(did) + publicKeyBase64 <- didResolutionResult match { + case failed: DIDResolutionFailed => + ZIO.fail( + PresentationError.UnexpectedError( + s"DIDResolutionFailed for $did: ${failed.error.toString}" + ) + ) + case succeeded: DIDResolutionSucceeded => + succeeded.didDocument.verificationMethod + .find(vm => succeeded.didDocument.assertionMethod.contains(vm.id)) + .flatMap(_.publicKeyJwk.flatMap(_.x)) + .toRight( + PresentationError.UnexpectedError( + s"Did Document is missing the required publicKey: $did" + ) + ) + .fold(ZIO.fail(_), ZIO.succeed(_)) + } + ed25519PublicKey <- ZIO + .fromTry { + val decodedKey = Base64.getUrlDecoder.decode(publicKeyBase64) + KmpEd25519KeyOps.publicKeyFromEncoded(decodedKey) + } + .mapError(t => PresentationError.UnexpectedError(t.getMessage)) + } yield ed25519PublicKey + } + } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala index 1cb1461f85..cb7ef87c76 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/IssueBackgroundJobs.scala @@ -237,6 +237,47 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { .gauge("issuance_flow_holder_req_pending_to_generated_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) + case IssueCredentialRecord( + id, + _, + _, + _, + _, + _, + _, + CredentialFormat.SDJWT, + Role.Holder, + Some(subjectId), + _, + _, + RequestPending, + Some(offer), + None, + _, + _, + _, + _, + _, + _, + _ + ) => + val holderPendingToGeneratedFlow = for { + walletAccessContext <- buildWalletAccessContextLayer(offer.to) + result <- (for { + credentialService <- ZIO.service[CredentialService] + _ <- credentialService + .generateSDJWTCredentialRequest(id) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield ()).mapError(e => (walletAccessContext, handleCredentialErrors(e))) + } yield result + + holderPendingToGeneratedFlow @@ HolderPendingToGeneratedSuccess.trackSuccess + @@ HolderPendingToGeneratedFailed.trackError + @@ HolderPendingToGeneratedAll + @@ Metric + .gauge("issuance_flow_holder_req_pending_to_generated_flow_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + case IssueCredentialRecord( id, _, @@ -420,6 +461,52 @@ object IssueBackgroundJobs extends BackgroundJobsHelper { .gauge("issuance_flow_issuer_cred_pending_to_generated_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) + // Credential is pending, can be generated by Issuer + case IssueCredentialRecord( + id, + _, + _, + _, + _, + _, + _, + CredentialFormat.SDJWT, + Role.Issuer, + _, + _, + _, + CredentialPending, + _, + _, + _, + Some(issue), + _, + Some(issuerDID), + _, + _, + _, + ) => + // Generate the JWT Credential and store it in DB as an attachment to IssueCredentialData + // Set ProtocolState to CredentialGenerated + // TODO Move all logic to service + val issuerPendingToGeneratedFlow = for { + walletAccessContext <- buildWalletAccessContextLayer(issue.from) + result <- (for { + credentialService <- ZIO.service[CredentialService] + config <- ZIO.service[AppConfig] + _ <- credentialService + .generateSDJWTCredential(id) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield ()).mapError(e => (walletAccessContext, e)) + } yield result + + issuerPendingToGeneratedFlow @@ IssuerPendingToGeneratedSuccess.trackSuccess + @@ IssuerPendingToGeneratedFailed.trackError + @@ IssuerPendingToGeneratedAll + @@ Metric + .gauge("issuance_flow_issuer_cred_pending_to_generated_flow_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + case IssueCredentialRecord( id, _, diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala index 5f2f3b4718..3915a1ccff 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/agent/server/jobs/PresentBackgroundJobs.scala @@ -24,6 +24,7 @@ import org.hyperledger.identus.shared.utils.DurationOps.toMetricsSeconds import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import zio.* import zio.json.ast.Json +import zio.json.* import zio.metrics.* import zio.prelude.Validation import zio.prelude.ZValidation.* @@ -31,10 +32,14 @@ import org.hyperledger.identus.agent.walletapi.storage.DIDNonSecretStorage import org.hyperledger.identus.agent.walletapi.model.error.DIDSecretStorageError.WalletNotFoundError import org.hyperledger.identus.resolvers.DIDResolver import org.hyperledger.identus.shared.models.WalletAccessContext +import org.hyperledger.identus.pollux.core.model.presentation.SdJwtPresentationPayload + import java.time.{Clock, Instant, ZoneId} import org.hyperledger.identus.castor.core.service.DIDService import org.hyperledger.identus.agent.walletapi.service.ManagedDIDService +import org.hyperledger.identus.pollux.sdjwt.{IssuerPublicKey} import org.hyperledger.identus.shared.http.* +import org.hyperledger.identus.pollux.sdjwt.SDJWT object PresentBackgroundJobs extends BackgroundJobsHelper { @@ -98,6 +103,43 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { jwtIssuer <- createJwtIssuer(longFormPrismDID, VerificationRelationship.Authentication) } yield jwtIssuer + // Holder / Prover Get the Holder/Prover PrismDID from the IssuedCredential + // When holder accepts offer he provides the subjectdid + private[this] def getPrismDIDForHolderFromCredentials( + presentationId: DidCommID, + credentialsToUse: Seq[String] + ) = + for { + credentialService <- ZIO.service[CredentialService] + // Choose first credential from the list to detect the subject DID to be used in Presentation. + // Holder binding check implies that any credential record can be chosen to detect the DID to use in VP. + credentialRecordId <- ZIO + .fromOption(credentialsToUse.headOption) + .mapError(_ => + PresentationError.UnexpectedError(s"No credential found in the Presentation record: $presentationId") + ) + credentialRecordUuid <- ZIO + .attempt(DidCommID(credentialRecordId)) + .mapError(_ => PresentationError.UnexpectedError(s"$credentialRecordId is not a valid DidCommID")) + vcSubjectId <- credentialService + .getIssueCredentialRecord(credentialRecordUuid) + .someOrFail(CredentialServiceError.RecordIdNotFound(credentialRecordUuid)) + .map(_.subjectId) + .someOrFail( + CredentialServiceError.UnexpectedError(s"VC SubjectId not found in credential record: $credentialRecordUuid") + ) + proverDID <- ZIO + .fromEither(PrismDID.fromString(vcSubjectId)) + .mapError(e => + PresentationError + .UnexpectedError( + s"One of the credential(s) subject is not a valid Prism DID: ${vcSubjectId}" + ) + ) + longFormPrismDID <- getLongForm(proverDID, true) + jwtIssuer <- getSDJwtIssuer(longFormPrismDID, VerificationRelationship.Authentication) + } yield jwtIssuer + private[this] def performPresentProofExchange(record: PresentationRecord): URIO[ AppConfig & DidOps & DIDResolver & JwtDidResolver & HttpClient & PresentationService & CredentialService & DIDNonSecretStorage & DIDService & ManagedDIDService, @@ -165,13 +207,13 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { // ########################## // ### PresentationRecord ### // ########################## - case PresentationRecord(id, _, _, _, _, _, _, _, ProposalPending, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, ProposalPending, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.fail(NotImplemented) - case PresentationRecord(id, _, _, _, _, _, _, _, ProposalSent, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, ProposalSent, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.fail(NotImplemented) - case PresentationRecord(id, _, _, _, _, _, _, _, ProposalReceived, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, ProposalReceived, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.fail(NotImplemented) - case PresentationRecord(id, _, _, _, _, _, _, _, ProposalRejected, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, ProposalRejected, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.fail(NotImplemented) case PresentationRecord( @@ -193,6 +235,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => // Verifier oRecord match @@ -233,17 +277,105 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { .gauge("present_proof_flow_verifier_req_pending_to_sent_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) - case PresentationRecord(id, _, _, _, _, _, _, _, RequestSent, _, _, _, _, _, _, _, _, _, _) => // Verifier + case PresentationRecord(id, _, _, _, _, _, _, _, RequestSent, _, _, _, _, _, _, _, _, _, _, _, _) => // Verifier ZIO.logDebug("PresentationRecord: RequestSent") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, RequestReceived, _, _, _, _, _, _, _, _, _, _) => // Prover + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + RequestReceived, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ) => // Prover ZIO.logDebug("PresentationRecord: RequestReceived") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, RequestRejected, _, _, _, _, _, _, _, _, _, _) => // Prover + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + RequestRejected, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ) => // Prover ZIO.logDebug("PresentationRecord: RequestRejected") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, ProblemReportPending, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + ProblemReportPending, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + ) => ZIO.fail(NotImplemented) - case PresentationRecord(id, _, _, _, _, _, _, _, ProblemReportSent, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, ProblemReportSent, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.fail(NotImplemented) - case PresentationRecord(id, _, _, _, _, _, _, _, ProblemReportReceived, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + ProblemReportReceived, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ) => ZIO.fail(NotImplemented) case PresentationRecord( id, @@ -264,6 +396,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => // Prover oRequestPresentation match @@ -299,7 +433,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { AttachmentDescriptor .buildBase64Attachment( payload = signedJwtPresentation.value.getBytes(), - mediaType = Some("prism/jwt") + mediaType = Some(PresentCredentialFormat.JWT.name) ) ), thid = requestPresentation.thid.orElse(Some(requestPresentation.id)), @@ -321,6 +455,58 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { @@ Metric .gauge("present_proof_flow_prover_presentation_pending_to_generated_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + PresentationPending, + CredentialFormat.SDJWT, + oRequestPresentation, + _, + _, + credentialsToUse, + _, + _, + _, + claimsToDisclose, + _, + _, + _ + ) => + // Prover + oRequestPresentation match + case None => ZIO.fail(InvalidState("PresentationRecord 'RequestPending' with no Record")) + case Some(requestPresentation) => // TODO create build method in mercury for Presentation + val proverPresentationPendingToGeneratedFlow = for { + walletAccessContext <- buildWalletAccessContextLayer(requestPresentation.to) + result <- (for { + presentationService <- ZIO.service[PresentationService] + prover <- getPrismDIDForHolderFromCredentials(id, credentialsToUse.getOrElse(Nil)) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + presentation <- + for { + presentation <- presentationService + .createSDJwtPresentation(id, requestPresentation, prover) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield presentation + _ <- presentationService + .markPresentationGenerated(id, presentation) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) + } yield ()).mapError(e => (walletAccessContext, handlePresentationErrors(e))) + } yield result + proverPresentationPendingToGeneratedFlow + @@ ProverPresentationPendingToGeneratedSuccess.trackSuccess + @@ ProverPresentationPendingToGeneratedFailed.trackError + @@ ProverPresentationPendingToGenerated + @@ Metric + .gauge("present_proof_flow_prover_presentation_pending_to_generated_flow_ms_gauge") + .trackDurationWith(_.toMetricsSeconds) + case PresentationRecord( id, _, @@ -340,6 +526,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { None, _, _, + _, + _, _ ) => // Prover ZIO.fail(NotImplemented) @@ -362,6 +550,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { Some(credentialsToUseJson), _, _, + _, + _, _ ) => // Prover oRequestPresentation match @@ -417,6 +607,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => // Prover ZIO.logDebug("PresentationRecord: PresentationGenerated") *> ZIO.unit @@ -459,7 +651,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { .gauge("present_proof_flow_prover_presentation_generated_to_sent_flow_ms_gauge") .trackDurationWith(_.toMetricsSeconds) - case PresentationRecord(id, _, _, _, _, _, _, _, PresentationSent, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, PresentationSent, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.logDebug("PresentationRecord: PresentationSent") *> ZIO.unit case PresentationRecord( id, @@ -480,6 +672,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => // Verifier ZIO.logDebug("PresentationRecord: PresentationReceived") *> ZIO.unit @@ -492,7 +686,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { walletAccessContext <- buildWalletAccessContextLayer(p.to) result <- (for { didResolverService <- ZIO.service[JwtDidResolver] - credentialsValidationResult <- p.attachments.head.data match { + credentialsClaimsValidationResult <- p.attachments.head.data match { case Base64(data) => val base64Decoded = new String(java.util.Base64.getDecoder.decode(data)) val maybePresentationOptions: Either[PresentationError, Option[ @@ -505,24 +699,20 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { decode[org.hyperledger.identus.mercury.model.JsonData]( attachment.data.asJson.noSpaces ) + .leftMap(err => PresentationDecodingError(s"JsonData decoding error: $err")) .flatMap(data => org.hyperledger.identus.pollux.core.model.presentation.PresentationAttachment.given_Decoder_PresentationAttachment .decodeJson(data.json.asJson) .map(_.options) .leftMap(err => - PresentationDecodingError( - new Throwable(s"PresentationAttachment decoding error: $err") - ) + PresentationDecodingError(s"PresentationAttachment decoding error: $err") ) ) - .leftMap(err => - PresentationDecodingError(new Throwable(s"JsonData decoding error: $err")) - ) ) .getOrElse(Right(None)) ) .getOrElse(Left(UnexpectedError("RequestPresentation NotFound"))) - val presentationValidationResult = for { + val presentationClaimsValidationResult = for { _ <- ZIO.fromEither(maybePresentationOptions.map { case Some(options) => JwtPresentation.validatePresentation( @@ -563,19 +753,19 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { .mapError(error => PresentationError.UnexpectedError(error.mkString)) } yield result - presentationValidationResult + presentationClaimsValidationResult case any => ZIO.fail(NotImplemented) } - _ <- credentialsValidationResult match - case l @ Failure(_, _) => ZIO.logError(s"CredentialsValidationResult: $l") - case l @ Success(_, _) => ZIO.logInfo(s"CredentialsValidationResult: $l") + _ <- credentialsClaimsValidationResult match + case l @ Failure(_, _) => ZIO.logError(s"CredentialsClaimsValidationResult: $l") + case l @ Success(_, _) => ZIO.logInfo(s"CredentialsClaimsValidationResult: $l") service <- ZIO.service[PresentationService] presReceivedToProcessedAspect = CustomMetricsAspect.endRecordingTime( s"${record.id}_present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge", "present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge" ) - _ <- credentialsValidationResult match { + _ <- credentialsClaimsValidationResult match { case Success(log, value) => service .markPresentationVerified(id) @@ -598,7 +788,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { resp <- MessagingService .send(reportproblem.toMessage) .provideSomeLayer(didCommAgent) - _ <- ZIO.log(s"CredentialsValidationResult: $error") + _ <- ZIO.log(s"CredentialsClaimsValidationResult: $error") } yield () } } @@ -614,6 +804,118 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { "present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_flow_ms_gauge" ) .trackDurationWith(_.toMetricsSeconds) + case PresentationRecord( + id, + _, + _, + _, + _, + _, + _, + _, + PresentationReceived, + CredentialFormat.SDJWT, + mayBeRequestPresentation, + _, + presentation, + _, + _, + _, + _, + _, + _, + _, + _ + ) => // Verifier + ZIO.logDebug("PresentationRecord: PresentationReceived SDJWT") *> ZIO.unit + presentation match + case None => ZIO.fail(InvalidState("PresentationRecord in 'PresentationReceived' with no Presentation")) + case Some(p) => + val verifierPresentationReceivedToProcessed = + for { + walletAccessContext <- buildWalletAccessContextLayer(p.to) + result <- (for { + didResolverService <- ZIO.service[JwtDidResolver] + credentialsClaimsValidationResult <- p.attachments.head.data match { + case Base64(data) => + val base64Decoded = new String(java.util.Base64.getDecoder.decode(data)) + val verifiedClaims = for { + sdJwtPresentationPayload <- ZIO.fromEither(base64Decoded.fromJson[SdJwtPresentationPayload]) + iss <- ZIO.fromEither(sdJwtPresentationPayload.presentation.iss) + ed25519PublicKey <- resolveToEd25519PublicKey(iss) + verifiedClaims = SDJWT.getVerifiedClaims( + IssuerPublicKey(ed25519PublicKey), + sdJwtPresentationPayload.presentation, + sdJwtPresentationPayload.claimsToDisclose.toJson + ) + _ <- ZIO.logInfo(s"ClaimsValidationResult: $verifiedClaims") + _ <- ZIO.logInfo(s"ClaimsValidationResult: ${sdJwtPresentationPayload.claimsToDisclose}") + result: SDJWT.ClaimsValidationResult = + verifiedClaims match { + case validClaims: SDJWT.ValidClaims => + validClaims.verifyDiscoseClaims( + sdJwtPresentationPayload.claimsToDisclose.asObject.getOrElse(Json.Obj()) + ) + case validAnyMatch: SDJWT.ValidAnyMatch.type => validAnyMatch + case invalid: SDJWT.Invalid => invalid + } + } yield result + verifiedClaims + .mapError(error => + UnexpectedError( + s"SDJWT PresentationReceived Error : $error" + ) + ) + case any => ZIO.fail(NotImplemented) + } + _ <- credentialsClaimsValidationResult match + case valid: SDJWT.Valid => + ZIO.logInfo(s"CredentialsClaimsValidationResult: $valid") + case invalid: SDJWT.Invalid => + ZIO.logError(s"CredentialsClaimsValidationResult: $invalid") + service <- ZIO.service[PresentationService] + presReceivedToProcessedAspect = CustomMetricsAspect.endRecordingTime( + s"${record.id}_present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge", + "present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_ms_gauge" + ) + _ <- credentialsClaimsValidationResult match + case valid: SDJWT.Valid => + service + .markPresentationVerified(id) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) @@ presReceivedToProcessedAspect + case invalid: SDJWT.Invalid => + for { + _ <- service + .markPresentationVerificationFailed(id) + .provideSomeLayer(ZLayer.succeed(walletAccessContext)) @@ presReceivedToProcessedAspect + didCommAgent <- buildDIDCommAgent(p.from).provideSomeLayer( + ZLayer.succeed(walletAccessContext) + ) + reportproblem = ReportProblem.build( + fromDID = p.to, + toDID = p.from, + pthid = p.thid.getOrElse(p.id), + code = ProblemCode("e.p.presentation-verification-failed"), + comment = Some(invalid.toString) + ) + resp <- MessagingService + .send(reportproblem.toMessage) + .provideSomeLayer(didCommAgent) + _ <- ZIO.log(s"CredentialsClaimsValidationResult: ${invalid.toString}") + } yield () + + } yield ()).mapError(e => (walletAccessContext, handlePresentationErrors(e))) + } yield result + verifierPresentationReceivedToProcessed + @@ VerifierPresentationReceivedToProcessedSuccess.trackSuccess + @@ VerifierPresentationReceivedToProcessedFailed.trackError + @@ VerifierPresentationReceivedToProcessed + @@ Metric + .gauge( + "present_proof_flow_verifier_presentation_received_to_verification_success_or_failure_flow_ms_gauge" + ) + .trackDurationWith(_.toMetricsSeconds) + case PresentationRecord( _, _, @@ -633,6 +935,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => ZIO.fail(InvalidState("PresentationRecord in 'PresentationReceived' with no Presentation Request")) @@ -655,6 +959,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => ZIO.fail(InvalidState("PresentationRecord in 'PresentationReceived' with no Presentation")) @@ -677,6 +983,8 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => // Verifier ZIO.logDebug("PresentationRecord: PresentationReceived") *> ZIO.unit @@ -707,7 +1015,7 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _ <- MessagingService .send(reportproblem.toMessage) .provideSomeLayer(didCommAgent) - _ <- ZIO.log(s"CredentialsValidationResult: ${e.toString}") + _ <- ZIO.log(s"CredentialsClaimsValidationResult: ${e.toString}") } yield () ZIO.succeed(e) ) @@ -741,14 +1049,16 @@ object PresentBackgroundJobs extends BackgroundJobsHelper { _, _, _, + _, + _, _ ) => ZIO.logDebug("PresentationRecord: PresentationVerificationFailed") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, PresentationAccepted, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, PresentationAccepted, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.logDebug("PresentationRecord: PresentationVerifiedAccepted") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, PresentationVerified, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, PresentationVerified, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.logDebug("PresentationRecord: PresentationVerified") *> ZIO.unit - case PresentationRecord(id, _, _, _, _, _, _, _, PresentationRejected, _, _, _, _, _, _, _, _, _, _) => + case PresentationRecord(id, _, _, _, _, _, _, _, PresentationRejected, _, _, _, _, _, _, _, _, _, _, _, _) => ZIO.logDebug("PresentationRecord: PresentationRejected") *> ZIO.unit } } yield () diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala index ac28323511..4a88e5b421 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/issue/controller/IssueControllerImpl.scala @@ -21,7 +21,7 @@ import org.hyperledger.identus.issue.controller.http.{ IssueCredentialRecord, IssueCredentialRecordPage } -import org.hyperledger.identus.pollux.core.model.CredentialFormat.{AnonCreds, JWT} +import org.hyperledger.identus.pollux.core.model.CredentialFormat.{AnonCreds, JWT, SDJWT} import org.hyperledger.identus.pollux.core.model.error.CredentialServiceError import org.hyperledger.identus.pollux.core.model.{CredentialFormat, DidCommID} import org.hyperledger.identus.pollux.core.service.CredentialService @@ -71,6 +71,25 @@ class IssueControllerImpl( issuingDID = issuingDID.asCanonical ) } yield record + case SDJWT => + for { + issuingDID <- ZIO + .fromOption(request.issuingDID) + .mapError(_ => ErrorResponse.badRequest(detail = Some("Missing request parameter: issuingDID"))) + .flatMap(extractPrismDIDFromString) + _ <- validatePrismDID(issuingDID, allowUnpublished = true, Role.Issuer) + record <- credentialService + .createSDJWTIssueCredentialRecord( + pairwiseIssuerDID = didIdPair.myDID, + pairwiseHolderDID = didIdPair.theirDid, + thid = DidCommID(), + maybeSchemaId = request.schemaId, + claims = jsonClaims, + validityPeriod = request.validityPeriod, + automaticIssuance = request.automaticIssuance.orElse(Some(true)), + issuingDID = issuingDID.asCanonical + ) + } yield record case AnonCreds => for { credentialDefinitionGUID <- ZIO @@ -175,15 +194,31 @@ class IssueControllerImpl( ): ZIO[WalletAccessContext, ErrorResponse, Unit] = { val result = for { maybeDIDState <- managedDIDService.getManagedDIDState(prismDID.asCanonical) - mayBeResolveDID <- didService - .resolveDID(prismDID) - maybeDidData = mayBeResolveDID.map(_._2) - maybeMetadata = mayBeResolveDID.map(_._1) - _ <- ZIO.when(role == Role.Holder)( + initialResolveDID <- didService.resolveDID(prismDID) + oInitialDidData = initialResolveDID.map(_._2) + mayBeResolveWithLongFormOrShortForm <- + if (oInitialDidData.isEmpty) { + for { + oLongFormDid <- getLongFormPrismDID(prismDID, allowUnpublished).provideSomeLayer( + ZLayer.succeed(managedDIDService) + ) + longFormDid <- ZIO + .fromOption(oLongFormDid) + .orElseFail( + ErrorResponse.badRequest(detail = Some(s"Longform of PrismDid Cannot be found: $oLongFormDid")) + ) + resolvedDID <- didService.resolveDID(longFormDid) + } yield resolvedDID + } else { + ZIO.succeed(initialResolveDID) + } + maybeDidData = mayBeResolveWithLongFormOrShortForm.map(_._2) + maybeMetadata = mayBeResolveWithLongFormOrShortForm.map(_._1) + _ <- ZIO.when(role == Role.Holder) { ZIO .fromOption(maybeDidData.flatMap(_.publicKeys.find(_.purpose == VerificationRelationship.Authentication))) .orElseFail(ErrorResponse.badRequest(detail = Some(s"Authentication key not found for the $prismDID"))) - ) + } _ <- ZIO.when(role == Role.Issuer)( ZIO .fromOption(maybeDidData.flatMap(_.publicKeys.find(_.purpose == VerificationRelationship.AssertionMethod))) diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/PresentProofControllerImpl.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/PresentProofControllerImpl.scala index 2992d35ca1..ba84b610fc 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/PresentProofControllerImpl.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/PresentProofControllerImpl.scala @@ -15,8 +15,10 @@ import org.hyperledger.identus.presentproof.controller.PresentProofController.to import org.hyperledger.identus.presentproof.controller.http.* import org.hyperledger.identus.shared.models.WalletAccessContext import zio.{URLayer, ZIO, ZLayer} - import java.util.UUID +import zio.json.* +import zio.json.ast.Json +import zio.* class PresentProofControllerImpl( presentationService: PresentationService, @@ -47,6 +49,34 @@ class PresentProofControllerImpl( }, options = request.options.map(x => Options(x.challenge, x.domain)) ) + case CredentialFormat.SDJWT => + request.claims match { + case Some(claims) => + for { + s <- presentationService.createSDJWTPresentationRecord( + pairwiseVerifierDID = didIdPair.myDID, + pairwiseProverDID = didIdPair.theirDid, + thid = DidCommID(), + connectionId = Some(request.connectionId.toString), + proofTypes = request.proofs.map { e => + ProofType( + schema = e.schemaId, + requiredFields = None, + trustIssuers = Some(e.trustIssuers.map(DidId(_))) + ) + }, + claimsToDisclose = claims, + options = request.options.map(o => Options(o.challenge, o.domain)) + ) + } yield s + + case None => + ZIO.fail( + PresentationError.MissingAnoncredPresentationRequest( + "presentation request is missing claims to be disclosed" + ) + ) + } case CredentialFormat.AnonCreds => request.anoncredPresentationRequest match { case Some(presentationRequest) => @@ -111,9 +141,18 @@ class PresentProofControllerImpl( record <- requestPresentationAction.action match { case "request-accept" => (requestPresentationAction.proofId, requestPresentationAction.anoncredPresentationRequest) match - case (Some(proofs), None) => - presentationService.acceptRequestPresentation(recordId = didCommId, credentialsToUse = proofs) - case (None, Some(proofs)) => + case (Some(proofs), None) => //// TODO based on CredentialFormat + val credentialFormat = + requestPresentationAction.credentialFormat.map(CredentialFormat.valueOf).getOrElse(CredentialFormat.JWT) + credentialFormat match + case CredentialFormat.SDJWT => + presentationService.acceptSDJWTRequestPresentation( + recordId = didCommId, + credentialsToUse = proofs, + claimsToDisclose = requestPresentationAction.claims + ) + case _ => presentationService.acceptRequestPresentation(recordId = didCommId, credentialsToUse = proofs) + case (None, Some(proofs)) => // TODO based on CredentialFormat Not sure why this was done like this presentationService.acceptAnoncredRequestPresentation( recordId = didCommId, credentialsToUse = proofs diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationAction.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationAction.scala index d104cce7a0..402e344f67 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationAction.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationAction.scala @@ -5,6 +5,7 @@ import org.hyperledger.identus.pollux.core.service.serdes.* import org.hyperledger.identus.presentproof.controller.http.RequestPresentationAction.annotations import sttp.tapir.Schema.annotations.{description, encodedExample, validate} import sttp.tapir.{Schema, Validator} +import sttp.tapir.json.zio.* import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} final case class RequestPresentationAction( @@ -18,6 +19,12 @@ final case class RequestPresentationAction( @description(annotations.anoncredProof.description) @encodedExample(annotations.anoncredProof.example) anoncredPresentationRequest: Option[AnoncredCredentialProofsV1], + @description(annotations.claims.description) + @encodedExample(annotations.claims.example) + claims: Option[zio.json.ast.Json.Obj], + @description(annotations.credentialFormat.description) + @encodedExample(annotations.credentialFormat.example) + credentialFormat: Option[String], ) object RequestPresentationAction { @@ -54,6 +61,33 @@ object RequestPresentationAction { "The unique identifier of the issue credential record - and hence VC - to use as the prover accepts the presentation request. Only applicable on the prover side when the action is `request-accept`.", example = "id" ) + + object claims + extends Annotation[Option[zio.json.ast.Json]]( + description = """ + |The set of claims to be disclosed from the issued credential. + |The JSON object should comply with the schema applicable for this offer (i.e. 'schemaId' or 'credentialDefinitionId'). + |""".stripMargin, + example = Some( + zio.json.ast.Json.Obj( + "firstname" -> zio.json.ast.Json.Str("Alice"), + "lastname" -> zio.json.ast.Json.Str("Wonderland"), + ) + ) + ) + + object credentialFormat + extends Annotation[Option[String]]( + description = "The credential format (default to 'JWT')", + example = Some("JWT"), + validator = Validator.enumeration( + List( + Some("JWT"), + Some("SDJWT"), + Some("AnonCreds") + ) + ) + ) } given RequestPresentationActionEncoder: JsonEncoder[RequestPresentationAction] = @@ -62,12 +96,12 @@ object RequestPresentationAction { given RequestPresentationActionDecoder: JsonDecoder[RequestPresentationAction] = DeriveJsonDecoder.gen[RequestPresentationAction] - given RequestPresentationActionSchema: Schema[RequestPresentationAction] = Schema.derived - import AnoncredCredentialProofsV1.given given Schema[AnoncredCredentialProofsV1] = Schema.derived given Schema[AnoncredCredentialProofV1] = Schema.derived + given RequestPresentationActionSchema: Schema[RequestPresentationAction] = Schema.derived + } diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationInput.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationInput.scala index 239925f999..13e1d3e442 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationInput.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/presentproof/controller/http/RequestPresentationInput.scala @@ -5,8 +5,8 @@ import org.hyperledger.identus.pollux.core.service.serdes.* import org.hyperledger.identus.presentproof.controller.http.RequestPresentationInput.annotations import sttp.tapir.Schema.annotations.{description, encodedExample} import sttp.tapir.{Schema, Validator} +import sttp.tapir.json.zio.* import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} - import java.util.UUID final case class RequestPresentationInput( @@ -22,6 +22,9 @@ final case class RequestPresentationInput( @description(annotations.anoncredPresentationRequest.description) @encodedExample(annotations.anoncredPresentationRequest.example) anoncredPresentationRequest: Option[AnoncredPresentationRequestV1], + @description(annotations.claims.description) + @encodedExample(annotations.claims.example) + claims: Option[zio.json.ast.Json.Obj], @description(annotations.credentialFormat.description) @encodedExample(annotations.credentialFormat.example) credentialFormat: Option[String], @@ -93,7 +96,19 @@ object RequestPresentationInput { ) ) ) - + object claims + extends Annotation[Option[zio.json.ast.Json.Obj]]( + description = """ + |The set of claims to be disclosed from the issued credential. + |The JSON object should comply with the schema applicable for this offer (i.e. 'schemaId' or 'credentialDefinitionId'). + |""".stripMargin, + example = Some( + zio.json.ast.Json.Obj( + "firstname" -> zio.json.ast.Json.Str("Alice"), + "lastname" -> zio.json.ast.Json.Str("Wonderland"), + ) + ) + ) object credentialFormat extends Annotation[Option[String]]( description = "The credential format (default to 'JWT')", @@ -101,6 +116,7 @@ object RequestPresentationInput { validator = Validator.enumeration( List( Some("JWT"), + Some("SDJWT"), Some("AnonCreds") ) ) diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerImplSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerImplSpec.scala index 341ba9f99d..3cf52c8c24 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerImplSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/issue/controller/IssueControllerImplSpec.scala @@ -11,7 +11,11 @@ import org.hyperledger.identus.connect.core.service import org.hyperledger.identus.connect.core.service.MockConnectionService import org.hyperledger.identus.container.util.MigrationAspects.migrate import org.hyperledger.identus.iam.authentication.AuthenticatorWithAuthZ -import org.hyperledger.identus.issue.controller.http.{AcceptCredentialOfferRequest, CreateIssueCredentialRecordRequest} +import org.hyperledger.identus.issue.controller.http.{ + AcceptCredentialOfferRequest, + CreateIssueCredentialRecordRequest, + IssueCredentialRecordPage +} import org.hyperledger.identus.mercury.model.DidId import org.hyperledger.identus.mercury.protocol.connection.ConnectionResponse import org.hyperledger.identus.mercury.protocol.invitation.v2.Invitation @@ -112,6 +116,11 @@ object IssueControllerImplSpec extends ZIOSpecDefault with IssueControllerTestTo None ) val acceptCredentialOfferRequest = AcceptCredentialOfferRequest( + Some( + "did:prism:332518729a7b7805f73a788e0944802527911901d9b7c16152281be9bc62d944" + ) + ) + val acceptCredentialOfferRequest2 = AcceptCredentialOfferRequest( Some( "did:prism:332518729a7b7805f73a788e0944802527911901d9b7c16152281be9bc62d944:CosBCogBEkkKFW15LWtleS1hdXRoZW50aWNhdGlvbhAESi4KCXNlY3AyNTZrMRIhAuYoRIefsLhkvYwHz8gDtkG2b0kaZTDOLj_SExWX1fOXEjsKB21hc3RlcjAQAUouCglzZWNwMjU2azESIQLOzab8f0ibt1P0zdMfoWDQTSlPc8_tkV9Jk5BBsXB8fA" ) @@ -243,10 +252,10 @@ object IssueControllerImplSpec extends ZIOSpecDefault with IssueControllerTestTo issueControllerService <- ZIO.service[IssueController] authenticator <- ZIO.service[AuthenticatorWithAuthZ[BaseEntity]] backend = httpBackend(issueControllerService, authenticator) - response: IssueCredentialBadRequestResponse <- basicRequest + response: IssueCredentialPageResponse <- basicRequest .post(uri"${issueUriBase}/records/123e4567-e89b-12d3-a456-426614174000/accept-offer") .body(acceptCredentialOfferRequest.toJsonPretty) - .response(asJsonAlways[ErrorResponse]) + .response(asJsonAlways[IssueCredentialRecordPage]) .send(backend) isSuccessRequestStatusCode = assert(response.code)(equalTo(StatusCode.Ok)) diff --git a/mercury/protocol-issue-credential/src/main/scala/org/hyperledger/identus/mercury/protocol/issuecredential/IssueFormats.scala b/mercury/protocol-issue-credential/src/main/scala/org/hyperledger/identus/mercury/protocol/issuecredential/IssueFormats.scala index 0ac5ab79dd..a617cb9db5 100644 --- a/mercury/protocol-issue-credential/src/main/scala/org/hyperledger/identus/mercury/protocol/issuecredential/IssueFormats.scala +++ b/mercury/protocol-issue-credential/src/main/scala/org/hyperledger/identus/mercury/protocol/issuecredential/IssueFormats.scala @@ -69,6 +69,7 @@ enum IssueCredentialProposeFormat(val name: String) { case Unsupported(other: String) extends IssueCredentialProposeFormat(other) // case JWT extends IssueCredentialProposeFormat("jwt/credential-propose@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends IssueCredentialProposeFormat("prism/jwt") // TODO REMOVE + case SDJWT extends IssueCredentialProposeFormat("vc+sd-jwt") case Anoncred extends IssueCredentialProposeFormat("anoncreds/credential-filter@v1.0") } @@ -96,6 +97,7 @@ enum IssueCredentialOfferFormat(val name: String) { case Unsupported(other: String) extends IssueCredentialOfferFormat(other) // case JWT extends IssueCredentialOfferFormat("jwt/credential-offer@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends IssueCredentialOfferFormat("prism/jwt") // TODO REMOVE + case SDJWT extends IssueCredentialOfferFormat("vc+sd-jwt") case Anoncred extends IssueCredentialOfferFormat("anoncreds/credential-offer@v1.0") } @@ -123,6 +125,7 @@ enum IssueCredentialRequestFormat(val name: String) { case Unsupported(other: String) extends IssueCredentialRequestFormat(other) // case JWT extends IssueCredentialRequestFormat("jwt/credential-request@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends IssueCredentialRequestFormat("prism/jwt") // TODO REMOVE + case SDJWT extends IssueCredentialRequestFormat("vc+sd-jwt") case Anoncred extends IssueCredentialRequestFormat("anoncreds/credential-request@v1.0") } @@ -148,6 +151,7 @@ enum IssueCredentialIssuedFormat(val name: String) { case Unsupported(other: String) extends IssueCredentialIssuedFormat(other) // case JWT extends IssueCredentialIssuedFormat("jwt/credential@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends IssueCredentialIssuedFormat("prism/jwt") // TODO REMOVE + case SDJWT extends IssueCredentialIssuedFormat("vc+sd-jwt") case Anoncred extends IssueCredentialIssuedFormat("anoncreds/credential@v1.0") } diff --git a/mercury/protocol-present-proof/src/main/scala/org/hyperledger/identus/mercury/protocol/presentproof/PresentFormats.scala b/mercury/protocol-present-proof/src/main/scala/org/hyperledger/identus/mercury/protocol/presentproof/PresentFormats.scala index 8eb6340370..85088572d9 100644 --- a/mercury/protocol-present-proof/src/main/scala/org/hyperledger/identus/mercury/protocol/presentproof/PresentFormats.scala +++ b/mercury/protocol-present-proof/src/main/scala/org/hyperledger/identus/mercury/protocol/presentproof/PresentFormats.scala @@ -49,6 +49,7 @@ enum PresentCredentialProposeFormat(val name: String) { case Unsupported(other: String) extends PresentCredentialProposeFormat(other) // case JWT extends PresentCredentialProposeFormat("jwt/proof-request@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends PresentCredentialProposeFormat("prism/jwt") // TODO REMOVE + case SDJWT extends PresentCredentialProposeFormat("vc+sd-jwt") case Anoncred extends PresentCredentialProposeFormat("anoncreds/proof-request@v1.0") } @@ -74,6 +75,7 @@ enum PresentCredentialRequestFormat(val name: String) { case Unsupported(other: String) extends PresentCredentialRequestFormat(other) // case JWT extends PresentCredentialRequestFormat("jwt/proof-request@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends PresentCredentialRequestFormat("prism/jwt") // TODO REMOVE + case SDJWT extends PresentCredentialRequestFormat("vc+sd-jwt") case Anoncred extends PresentCredentialRequestFormat("anoncreds/proof-request@v1.0") } @@ -99,6 +101,7 @@ enum PresentCredentialFormat(val name: String) { case Unsupported(other: String) extends PresentCredentialFormat(other) // case JWT extends PresentCredentialFormat("jwt/proof-request@v1.0") // TODO FOLLOW specs for JWT VC case JWT extends PresentCredentialFormat("prism/jwt") // TODO REMOVE + case SDJWT extends PresentCredentialFormat("vc+sd-jwt") case Anoncred extends PresentCredentialFormat("anoncreds/proof-request@v1.0") } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/CredentialFormat.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/CredentialFormat.scala index 94bf99ca62..39c7f9282a 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/CredentialFormat.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/CredentialFormat.scala @@ -2,11 +2,13 @@ package org.hyperledger.identus.pollux.core.model enum CredentialFormat: case JWT extends CredentialFormat + case SDJWT extends CredentialFormat case AnonCreds extends CredentialFormat object CredentialFormat { def fromString(str: String) = str match case "JWT" => Some(CredentialFormat.JWT) + case "SDJWT" => Some(CredentialFormat.SDJWT) case "AnonCreds" => Some(CredentialFormat.AnonCreds) case _ => None } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/IssueCredentialRecord.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/IssueCredentialRecord.scala index f9c6d1b5ad..10a6b15644 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/IssueCredentialRecord.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/IssueCredentialRecord.scala @@ -44,18 +44,21 @@ final case class IssueCredentialRecord( offerCredentialData.map { data => credentialFormat.match case CredentialFormat.JWT => (IssueCredentialOfferFormat.JWT, data) + case CredentialFormat.SDJWT => (IssueCredentialOfferFormat.SDJWT, data) case CredentialFormat.AnonCreds => (IssueCredentialOfferFormat.Anoncred, data) } def requestCredentialFormatAndData: Option[(IssueCredentialRequestFormat, RequestCredential)] = requestCredentialData.map { data => credentialFormat.match case CredentialFormat.JWT => (IssueCredentialRequestFormat.JWT, data) + case CredentialFormat.SDJWT => (IssueCredentialRequestFormat.SDJWT, data) case CredentialFormat.AnonCreds => (IssueCredentialRequestFormat.Anoncred, data) } def issuedCredentialFormatAndData: Option[(IssueCredentialIssuedFormat, IssueCredential)] = issueCredentialData.map { data => credentialFormat.match case CredentialFormat.JWT => (IssueCredentialIssuedFormat.JWT, data) + case CredentialFormat.SDJWT => (IssueCredentialIssuedFormat.SDJWT, data) case CredentialFormat.AnonCreds => (IssueCredentialIssuedFormat.Anoncred, data) } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/PresentationRecord.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/PresentationRecord.scala index 874c46348d..5a1eb1761a 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/PresentationRecord.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/PresentationRecord.scala @@ -7,6 +7,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit type AnoncredCredentialProofs = zio.json.ast.Json +type SdJwtCredentialToDisclose = zio.json.ast.Json.Obj final case class PresentationRecord( id: DidCommID, @@ -25,6 +26,8 @@ final case class PresentationRecord( credentialsToUse: Option[List[String]], anoncredCredentialsToUseJsonSchemaId: Option[String], anoncredCredentialsToUse: Option[AnoncredCredentialProofs], + sdJwtClaimsToUseJsonSchemaId: Option[String], + sdJwtClaimsToDisclose: Option[SdJwtCredentialToDisclose], metaRetries: Int, metaNextRetry: Option[Instant], metaLastFailure: Option[String], diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/error/PresentationError.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/error/PresentationError.scala index 3957089e68..afa97db727 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/error/PresentationError.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/error/PresentationError.scala @@ -10,9 +10,9 @@ object PresentationError { final case class ThreadIdNotFound(thid: DidCommID) extends PresentationError final case class InvalidFlowStateError(msg: String) extends PresentationError final case class UnexpectedError(msg: String) extends PresentationError - final case class IssuedCredentialNotFoundError(cause: Throwable) extends PresentationError + final case class IssuedCredentialNotFoundError(cause: String) extends PresentationError final case class NotMatchingPresentationCredentialFormat(cause: Throwable) extends PresentationError - final case class PresentationDecodingError(cause: Throwable) extends PresentationError + final case class PresentationDecodingError(cause: String) extends PresentationError final case class PresentationNotFoundError(cause: Throwable) extends PresentationError final case class HolderBindingError(msg: String) extends PresentationError object MissingCredential extends PresentationError diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/presentation/SdJwtPresentationPayload.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/presentation/SdJwtPresentationPayload.scala new file mode 100644 index 0000000000..5f53b52da1 --- /dev/null +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/model/presentation/SdJwtPresentationPayload.scala @@ -0,0 +1,17 @@ +package org.hyperledger.identus.pollux.core.model.presentation + +import zio.json.* +import org.hyperledger.identus.pollux.core.model.presentation.Options +import org.hyperledger.identus.pollux.sdjwt.PresentationJson + +case class SdJwtPresentationPayload( + claimsToDisclose: ast.Json.Obj, + presentation: PresentationJson, + options: Option[Options] +) +object SdJwtPresentationPayload { + given JsonDecoder[Options] = DeriveJsonDecoder.gen[Options] + given JsonEncoder[Options] = DeriveJsonEncoder.gen[Options] + given JsonDecoder[SdJwtPresentationPayload] = DeriveJsonDecoder.gen[SdJwtPresentationPayload] + given JsonEncoder[SdJwtPresentationPayload] = DeriveJsonEncoder.gen[SdJwtPresentationPayload] +} diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala index 2a04082ea4..731b58c044 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepository.scala @@ -51,6 +51,13 @@ trait PresentationRepository { protocolState: ProtocolState ): RIO[WalletAccessContext, Int] + def updateSDJWTPresentationWithCredentialsToUse( + recordId: DidCommID, + credentialsToUse: Option[Seq[String]], + sdJwtClaimsToDisclose: Option[SdJwtCredentialToDisclose], + protocolState: ProtocolState + ): RIO[WalletAccessContext, Int] + def updateAnoncredPresentationWithCredentialsToUse( recordId: DidCommID, anoncredCredentialsToUseJsonSchemaId: Option[String], diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala index f96a943002..5fdca1b025 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositoryInMemory.scala @@ -112,7 +112,36 @@ class PresentationRepositoryInMemory( .getOrElse(ZIO.succeed(0)) } yield count } - + override def updateSDJWTPresentationWithCredentialsToUse( + recordId: DidCommID, + credentialsToUse: Option[Seq[String]], + sdJwtClaimsToDisclose: Option[SdJwtCredentialToDisclose], + protocolState: ProtocolState + ): RIO[WalletAccessContext, Int] = { + for { + storeRef <- walletStoreRef + maybeRecord <- getPresentationRecord(recordId) + count <- maybeRecord + .map(record => + for { + _ <- storeRef.update(r => + r.updated( + recordId, + record.copy( + updatedAt = Some(Instant.now), + credentialsToUse = credentialsToUse.map(_.toList), + sdJwtClaimsToDisclose = sdJwtClaimsToDisclose, + protocolState = protocolState, + metaRetries = maxRetries, + metaLastFailure = None, + ) + ) + ) + } yield 1 + ) + .getOrElse(ZIO.succeed(0)) + } yield count + } def updateAnoncredPresentationWithCredentialsToUse( recordId: DidCommID, anoncredCredentialsToUseJsonSchemaId: Option[String], diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialService.scala index 8688892f9e..ff0e407a9c 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialService.scala @@ -33,6 +33,17 @@ trait CredentialService { issuingDID: CanonicalPrismDID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def createSDJWTIssueCredentialRecord( + pairwiseIssuerDID: DidId, + pairwiseHolderDID: DidId, + thid: DidCommID, + maybeSchemaId: Option[String], + claims: io.circe.Json, + validityPeriod: Option[Double] = None, + automaticIssuance: Option[Boolean], + issuingDID: CanonicalPrismDID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def createAnonCredsIssueCredentialRecord( pairwiseIssuerDID: DidId, pairwiseHolderDID: DidId, @@ -85,6 +96,10 @@ trait CredentialService { recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def generateSDJWTCredentialRequest( + recordId: DidCommID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def generateAnonCredsCredentialRequest( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] @@ -102,6 +117,10 @@ trait CredentialService { statusListRegistryUrl: String, ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def generateSDJWTCredential( + recordId: DidCommID, + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] + def generateAnonCredsCredential( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] @@ -170,4 +189,25 @@ object CredentialService { } } yield claims } + + def convertAttributesToMapClaims( + attributes: Seq[Attribute] + ): IO[CredentialServiceError, JsonObject] = { + for { + claims <- ZIO.foldLeft(attributes)(JsonObject()) { case (jsonObject, attr) => + attr.media_type match + case None => + ZIO.succeed(jsonObject.add(attr.name, attr.value.asJson)) + + case Some("application/json") => + val jsonBytes = java.util.Base64.getUrlDecoder.decode(attr.value.getBytes(StandardCharsets.UTF_8)) + io.circe.parser.parse(new String(jsonBytes, StandardCharsets.UTF_8)) match + case Right(value) => ZIO.succeed(jsonObject.add(attr.name, value)) + case Left(error) => ZIO.fail(UnsupportedVCClaimsValue(error.message)) + + case Some(media_type) => + ZIO.fail(UnsupportedVCClaimsMediaType(media_type)) + } + } yield claims + } } diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala index eac437c930..1d62c1339b 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceImpl.scala @@ -1,5 +1,6 @@ package org.hyperledger.identus.pollux.core.service +import com.nimbusds.jose.jwk.OctetKeyPair import io.circe.Json import io.circe.syntax.* import org.hyperledger.identus.agent.walletapi.model.{ManagedDIDState, PublicationState} @@ -31,12 +32,18 @@ import org.hyperledger.identus.shared.models.WalletAccessContext import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import zio.* import zio.prelude.ZValidation +import org.hyperledger.identus.castor.core.model.did.EllipticCurve import java.net.URI import java.rmi.UnexpectedException import java.time.{Instant, ZoneId} import java.util.UUID import scala.language.implicitConversions +import org.hyperledger.identus.pollux.sdjwt +import org.hyperledger.identus.pollux.sdjwt.* +import org.hyperledger.identus.shared.crypto.{Ed25519KeyPair, Ed25519PublicKey} + +import java.time.temporal.ChronoUnit object CredentialServiceImpl { val layer: URLayer[ @@ -190,6 +197,70 @@ class CredentialServiceImpl( } yield record } + def createSDJWTIssueCredentialRecord( + pairwiseIssuerDID: DidId, + pairwiseHolderDID: DidId, + thid: DidCommID, + maybeSchemaId: Option[String], + claims: io.circe.Json, + validityPeriod: Option[Double] = None, + automaticIssuance: Option[Boolean], + issuingDID: CanonicalPrismDID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = + for { + _ <- maybeSchemaId match + case Some(schemaId) => + CredentialSchema + .validateJWTCredentialSubject(schemaId, claims.noSpaces, uriDereferencer) + .mapError(e => CredentialSchemaError(e)) + case None => + ZIO.unit + attributes <- CredentialService.convertJsonClaimsToAttributes(claims) + offer <- createSDJWTDidCommOfferCredential( + pairwiseIssuerDID = pairwiseIssuerDID, + pairwiseHolderDID = pairwiseHolderDID, + maybeSchemaId = maybeSchemaId, + claims = attributes, + thid = thid, + UUID.randomUUID().toString, + "domain" + ) + record <- ZIO.succeed( + IssueCredentialRecord( + id = DidCommID(), + createdAt = Instant.now, + updatedAt = None, + thid = thid, + schemaUri = maybeSchemaId, + credentialDefinitionId = None, + credentialDefinitionUri = None, + credentialFormat = CredentialFormat.SDJWT, + role = IssueCredentialRecord.Role.Issuer, + subjectId = None, + validityPeriod = validityPeriod, + automaticIssuance = automaticIssuance, + protocolState = IssueCredentialRecord.ProtocolState.OfferPending, + offerCredentialData = Some(offer), + requestCredentialData = None, + anonCredsRequestMetadata = None, + issueCredentialData = None, + issuedCredentialRaw = None, + issuingDID = Some(issuingDID), + metaRetries = maxRetries, + metaNextRetry = Some(Instant.now()), + metaLastFailure = None, + ) + ) + count <- credentialRepository + .createIssueCredentialRecord(record) + .flatMap { + case 1 => ZIO.succeed(()) + case n => ZIO.fail(UnexpectedException(s"Invalid row count result: $n")) + } + .mapError(RepositoryError.apply) @@ CustomMetricsAspect + .startRecordingTime(s"${record.id}_issuer_offer_pending_to_sent_ms_gauge") + } yield record + override def createAnonCredsIssueCredentialRecord( pairwiseIssuerDID: DidId, pairwiseHolderDID: DidId, @@ -290,6 +361,7 @@ class CredentialServiceImpl( credentialFormat <- format match case value if value == IssueCredentialOfferFormat.JWT.name => ZIO.succeed(CredentialFormat.JWT) + case value if value == IssueCredentialOfferFormat.SDJWT.name => ZIO.succeed(CredentialFormat.SDJWT) case value if value == IssueCredentialOfferFormat.Anoncred.name => ZIO.succeed(CredentialFormat.AnonCreds) case value => ZIO.fail(UnsupportedCredentialFormat(value)) @@ -335,7 +407,7 @@ class CredentialServiceImpl( attachment: AttachmentDescriptor ) = for { _ <- credentialFormat match - case CredentialFormat.JWT => + case CredentialFormat.JWT | CredentialFormat.SDJWT => attachment.data match case JsonData(json) => ZIO @@ -344,6 +416,11 @@ class CredentialServiceImpl( CredentialServiceError .UnexpectedError(s"Unexpected error parsing credential offer attachment: ${e.toString}") ) + case Base64(base64) => // TODO + ZIO.fail( + CredentialServiceError + .UnexpectedError(s"A Base64 attachment it's not yet implemented for credential offer") + ) case _ => ZIO.fail( CredentialServiceError @@ -386,6 +463,17 @@ class CredentialServiceImpl( s"${record.id}_issuance_flow_holder_req_pending_to_generated" ) } yield count + case (CredentialFormat.SDJWT, Some(subjectId)) => + for { + _ <- ZIO + .fromEither(PrismDID.fromString(subjectId)) + .mapError(_ => CredentialServiceError.UnsupportedDidFormat(subjectId)) + count <- credentialRepository + .updateWithSubjectId(recordId, subjectId, ProtocolState.RequestPending) + .mapError(RepositoryError.apply) @@ CustomMetricsAspect.startRecordingTime( + s"${record.id}_issuance_flow_holder_req_pending_to_generated" + ) + } yield count case (CredentialFormat.AnonCreds, None) => credentialRepository .updateCredentialRecordProtocolState(recordId, ProtocolState.OfferReceived, ProtocolState.RequestPending) @@ -479,6 +567,58 @@ class CredentialServiceImpl( } yield jwtIssuer } + private[this] def getEd25519SigningKeyPair( + jwtIssuerDID: PrismDID, + verificationRelationship: VerificationRelationship + ) = { + for { + issuingKeyId <- didService + .resolveDID(jwtIssuerDID) + .mapError(e => UnexpectedError(s"Error occured while resolving Issuing DID during VC creation: ${e.toString}")) + .someOrFail(UnexpectedError(s"Issuing DID resolution result is not found")) + .map { case (_, didData) => didData.publicKeys.find(_.purpose == verificationRelationship).map(_.id) } + .someOrFail( + UnexpectedError(s"Issuing DID doesn't have a key in ${verificationRelationship.name} to use: $jwtIssuerDID") + ) + ed25519keyPair <- managedDIDService + .findDIDKeyPair(jwtIssuerDID.asCanonical, issuingKeyId) + .map(_.collect { case keyPair: Ed25519KeyPair => keyPair }) + .mapError(e => UnexpectedError(s"Error occurred while getting issuer key-pair: ${e.toString}")) + .someOrFail( + UnexpectedError(s"Issuer key-pair does not exist in the wallet: ${jwtIssuerDID.toString}#$issuingKeyId") + ) + } yield ed25519keyPair + } + + /** @param jwtIssuerDID + * This can holder prism did / issuer prism did + * @param verificationRelationship + * Holder it Authentication and Issuer it is AssertionMethod + * @return + * JwtIssuer + * @see + * org.hyperledger.identus.pollux.vc.jwt.Issuer + */ + private[this] def getSDJwtIssuer( + jwtIssuerDID: PrismDID, + verificationRelationship: VerificationRelationship + ): ZIO[WalletAccessContext, UnexpectedError, JwtIssuer] = { + for { + ed25519keyPair <- getEd25519SigningKeyPair(jwtIssuerDID, verificationRelationship) + } yield { + + val d = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(ed25519keyPair.privateKey.getEncoded) + val x = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(ed25519keyPair.publicKey.getEncoded) + val okpJson = s"""{"kty":"OKP","crv":"Ed25519","d":"$d","x":"$x"}""" + val octetKeyPair = OctetKeyPair.parse(okpJson) + JwtIssuer( + org.hyperledger.identus.pollux.vc.jwt.DID(jwtIssuerDID.toString), + EdSigner(ed25519keyPair), + Ed25519PublicKey.toJavaEd25519PublicKey(ed25519keyPair.publicKey.getEncoded) + ) + } + } + override def generateJWTCredentialRequest( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { @@ -517,6 +657,45 @@ class CredentialServiceImpl( } yield record } + // Holder requesting the credential + override def generateSDJWTCredentialRequest( + recordId: DidCommID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { + for { + record <- getRecordWithState(recordId, ProtocolState.RequestPending) + subjectId <- ZIO + .fromOption(record.subjectId) + .mapError(_ => CredentialServiceError.UnexpectedError(s"Subject Id not found in record: ${recordId.value}")) + subjectDID <- ZIO + .fromEither(PrismDID.fromString(subjectId)) + .mapError(_ => CredentialServiceError.UnsupportedDidFormat(subjectId)) + longFormPrismDID <- getLongForm(subjectDID, true).mapError(err => UnexpectedError(err.getMessage)) + jwtIssuer <- getSDJwtIssuer(longFormPrismDID, VerificationRelationship.Authentication) + presentationPayload <- createPresentationPayload(record, jwtIssuer) + signedPayload = JwtPresentation.encodeJwt(presentationPayload.toJwtPresentationPayload, jwtIssuer) + formatAndOffer <- ZIO + .fromOption(record.offerCredentialFormatAndData) + .mapError(_ => InvalidFlowStateError(s"No offer found for this record: $recordId")) + request = createDidCommRequestCredential(formatAndOffer._1, formatAndOffer._2, signedPayload) + count <- credentialRepository + .updateWithJWTRequestCredential(recordId, request, ProtocolState.RequestGenerated) + .mapError(RepositoryError.apply) @@ CustomMetricsAspect.endRecordingTime( + s"${record.id}_issuance_flow_holder_req_pending_to_generated", + "issuance_flow_holder_req_pending_to_generated_ms_gauge" + ) @@ CustomMetricsAspect.startRecordingTime(s"${record.id}_issuance_flow_holder_req_generated_to_sent") + _ <- count match + case 1 => ZIO.succeed(()) + case n => ZIO.fail(RecordIdNotFound(recordId)) + record <- credentialRepository + .getIssueCredentialRecord(record.id) + .mapError(RepositoryError.apply) + .flatMap { + case None => ZIO.fail(RecordIdNotFound(recordId)) + case Some(value) => ZIO.succeed(value) + } + } yield record + } + override def generateAnonCredsCredentialRequest( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { @@ -892,6 +1071,42 @@ class CredentialServiceImpl( ) } + private[this] def createSDJWTDidCommOfferCredential( + pairwiseIssuerDID: DidId, + pairwiseHolderDID: DidId, + maybeSchemaId: Option[String], + claims: Seq[Attribute], + thid: DidCommID, + challenge: String, + domain: String + ) = { + for { + credentialPreview <- ZIO.succeed(CredentialPreview(schema_id = maybeSchemaId, attributes = claims)) + body = OfferCredential.Body( + goal_code = Some("Offer Credential"), + credential_preview = credentialPreview, + ) + attachments <- ZIO.succeed( + Seq( + AttachmentDescriptor.buildJsonAttachment( + mediaType = Some("application/json"), + format = Some(IssueCredentialOfferFormat.SDJWT.name), + payload = PresentationAttachment( + Some(Options(challenge, domain)), // TODO holder binding ATL-7183 + PresentationDefinition(format = Some(ClaimFormat(jwt = Some(Jwt(alg = Seq("ES256K"), proof_type = Nil))))) + ) + ) + ) + ) + } yield OfferCredential( + body = body, + attachments = attachments, + from = pairwiseIssuerDID, + to = pairwiseHolderDID, + thid = Some(thid.value) + ) + } + private[this] def createAnonCredsDidCommOfferCredential( pairwiseIssuerDID: DidId, pairwiseHolderDID: DidId, @@ -1107,6 +1322,73 @@ class CredentialServiceImpl( record <- markCredentialGenerated(record, issueCredential) } yield record } + // Issuer Generating the credential + override def generateSDJWTCredential( + recordId: DidCommID, + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = { + for { + record <- getRecordWithState(recordId, ProtocolState.CredentialPending) + issuingDID <- ZIO + .fromOption(record.issuingDID) + .mapError(_ => CredentialServiceError.UnexpectedError(s"Issuing Id not found in record: ${recordId.value}")) + issue <- ZIO + .fromOption(record.issueCredentialData) + .mapError(_ => + CredentialServiceError.UnexpectedError(s"Issue credential data not found in record: ${recordId.value}") + ) + longFormPrismDID <- getLongForm(issuingDID, true).mapError(err => UnexpectedError(err.getMessage)) + maybeOfferOptions <- getOptionsFromOfferCredentialData(record) + requestJwt <- getJwtFromRequestCredentialData(record) + + // domain/challenge validation + JWT verification + jwtPresentation <- validateRequestCredentialDataProof(maybeOfferOptions, requestJwt).tapBoth( + error => + ZIO.logErrorCause("JWT Presentation Validation Failed!!", Cause.fail(error)) *> credentialRepository + .updateCredentialRecordProtocolState( + record.id, + ProtocolState.CredentialPending, + ProtocolState.ProblemReportPending + ) + .mapError(t => RepositoryError(t)), + payload => ZIO.logInfo("JWT Presentation Validation Successful!") + ) + ed25519KeyPair <- getEd25519SigningKeyPair(longFormPrismDID, VerificationRelationship.AssertionMethod) + offerCredentialData <- ZIO + .fromOption(record.offerCredentialData) + .mapError(_ => + CredentialServiceError.CreateCredentialPayloadFromRecordError( + new Throwable("Could not extract claims from \"requestCredential\" DIDComm message") + ) + ) + preview = offerCredentialData.body.credential_preview + claims <- CredentialService.convertAttributesToJsonClaims(preview.body.attributes) + sdJwtPrivateKey = sdjwt.IssuerPrivateKey(ed25519KeyPair.privateKey) + didDocResult <- didResolver.resolve(jwtPresentation.iss) map { + case failed: DIDResolutionFailed => CredentialServiceError.UnexpectedError(failed.error.toString) + case succeeded: DIDResolutionSucceeded => succeeded.didDocument.authentication.map(x => x) + } + now = Instant.now.getEpochSecond + in30Days = Instant.now.plus(30, ChronoUnit.DAYS).getEpochSecond // FIXME hardcode 30days + claimsUpdated = claims + .add("iss", issuingDID.did.toString.asJson) + .add("sub", jwtPresentation.iss.asJson) // This is subject did + .add("iat", now.asJson) + .add("exp", in30Days.asJson) + credential = SDJWT.issueCredential( + sdJwtPrivateKey, + claimsUpdated.asJson.noSpaces, + ) // FIXME TO ADD Key of the Holder This issue is also with JWT + + issueCredential = IssueCredential.build( + fromDID = issue.from, + toDID = issue.to, + thid = issue.thid, + credentials = Seq(IssueCredentialIssuedFormat.SDJWT -> credential.value.getBytes) + ) + record <- markCredentialGenerated(record, issueCredential) + } yield record + + } private[this] def allocateNewCredentialInStatusListForWallet( record: IssueCredentialRecord, @@ -1262,7 +1544,7 @@ class CredentialServiceImpl( jwt <- attachmentDescriptor.data match case Base64(b64) => ZIO.succeed { - val base64Decoded = new String(java.util.Base64.getDecoder().decode(b64)) + val base64Decoded = new String(java.util.Base64.getDecoder.decode(b64)) JWT(base64Decoded) } case _ => ZIO.fail(UnexpectedError(s"Attachment doesn't contain Base64Data: ${record.id}")) diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceNotifier.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceNotifier.scala index 7e45998e01..84cd183d3b 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceNotifier.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/CredentialServiceNotifier.scala @@ -43,6 +43,29 @@ class CredentialServiceNotifier( ) ) + override def createSDJWTIssueCredentialRecord( + pairwiseIssuerDID: DidId, + pairwiseHolderDID: DidId, + thid: DidCommID, + maybeSchemaId: Option[String], + claims: io.circe.Json, + validityPeriod: Option[Double] = None, + automaticIssuance: Option[Boolean], + issuingDID: CanonicalPrismDID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = + notifyOnSuccess( + svc.createSDJWTIssueCredentialRecord( + pairwiseIssuerDID, + pairwiseHolderDID, + thid, + maybeSchemaId, + claims, + validityPeriod, + automaticIssuance, + issuingDID + ) + ) + override def createAnonCredsIssueCredentialRecord( pairwiseIssuerDID: DidId, pairwiseHolderDID: DidId, @@ -87,6 +110,11 @@ class CredentialServiceNotifier( ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = notifyOnSuccess(svc.generateJWTCredentialRequest(recordId)) + override def generateSDJWTCredentialRequest( + recordId: DidCommID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = + notifyOnSuccess(svc.generateSDJWTCredentialRequest(recordId)) + override def generateAnonCredsCredentialRequest( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = @@ -123,6 +151,11 @@ class CredentialServiceNotifier( ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = notifyOnSuccess(svc.generateJWTCredential(recordId, statusListRegistryUrl)) + override def generateSDJWTCredential( + recordId: DidCommID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = + notifyOnSuccess(svc.generateSDJWTCredential(recordId)) + override def generateAnonCredsCredential( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockCredentialService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockCredentialService.scala index 46ece67626..d7b1a826bb 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockCredentialService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockCredentialService.scala @@ -30,6 +30,21 @@ object MockCredentialService extends Mock[CredentialService] { CredentialServiceError, IssueCredentialRecord ] + object CreateSDJWTIssueCredentialRecord + extends Effect[ + ( + DidId, + DidId, + DidCommID, + Option[String], + Json, + Option[Double], + Option[Boolean], + CanonicalPrismDID + ), + CredentialServiceError, + IssueCredentialRecord + ] object CreateAnonCredsIssueCredentialRecord extends Effect[ @@ -51,10 +66,12 @@ object MockCredentialService extends Mock[CredentialService] { object AcceptCredentialOffer extends Effect[(DidCommID, Option[String]), CredentialServiceError, IssueCredentialRecord] object GenerateJWTCredentialRequest extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] + object GenerateSDJWTCredentialRequest extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] 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, String), CredentialServiceError, IssueCredentialRecord] + object GenerateSDJWTCredential extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] object GenerateAnonCredsCredential extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] object ReceiveCredentialIssue extends Effect[IssueCredential, CredentialServiceError, IssueCredentialRecord] object MarkOfferSent extends Effect[DidCommID, CredentialServiceError, IssueCredentialRecord] @@ -92,6 +109,28 @@ object MockCredentialService extends Mock[CredentialService] { issuingDID ) + override def createSDJWTIssueCredentialRecord( + pairwiseIssuerDID: DidId, + pairwiseHolderDID: DidId, + thid: DidCommID, + maybeSchemaId: Option[String], + claims: Json, + validityPeriod: Option[Double], + automaticIssuance: Option[Boolean], + issuingDID: CanonicalPrismDID + ): IO[CredentialServiceError, IssueCredentialRecord] = + proxy( + CreateSDJWTIssueCredentialRecord, + pairwiseIssuerDID, + pairwiseHolderDID, + thid, + maybeSchemaId, + claims, + validityPeriod, + automaticIssuance, + issuingDID + ) + override def createAnonCredsIssueCredentialRecord( pairwiseIssuerDID: DidId, pairwiseHolderDID: DidId, @@ -128,6 +167,11 @@ object MockCredentialService extends Mock[CredentialService] { ): IO[CredentialServiceError, IssueCredentialRecord] = proxy(GenerateJWTCredentialRequest, recordId) + override def generateSDJWTCredentialRequest( + recordId: DidCommID + ): IO[CredentialServiceError, IssueCredentialRecord] = + proxy(GenerateSDJWTCredentialRequest, recordId) + override def generateAnonCredsCredentialRequest( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = @@ -147,6 +191,11 @@ object MockCredentialService extends Mock[CredentialService] { ): IO[CredentialServiceError, IssueCredentialRecord] = proxy(GenerateJWTCredential, recordId, statusListRegistryUrl) + override def generateSDJWTCredential( + recordId: DidCommID + ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = + proxy(GenerateSDJWTCredential, recordId) + override def generateAnonCredsCredential( recordId: DidCommID ): ZIO[WalletAccessContext, CredentialServiceError, IssueCredentialRecord] = diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala index 7093eaed45..fa82ec51fb 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/MockPresentationService.scala @@ -12,11 +12,13 @@ import org.hyperledger.identus.pollux.core.model.error.PresentationError import org.hyperledger.identus.pollux.core.model.presentation.Options import org.hyperledger.identus.pollux.core.model.{DidCommID, PresentationRecord} import org.hyperledger.identus.pollux.core.service.serdes.{AnoncredCredentialProofsV1, AnoncredPresentationRequestV1} +import org.hyperledger.identus.pollux.core.model.presentation.SdJwtPresentationPayload +import org.hyperledger.identus.pollux.sdjwt.PresentationJson import org.hyperledger.identus.pollux.vc.jwt.{Issuer, PresentationPayload, W3cCredentialPayload} import org.hyperledger.identus.shared.models.WalletAccessContext import zio.mock.{Mock, Proxy} import zio.{IO, URLayer, ZIO, ZLayer, mock} - +import zio.json.* import java.time.Instant import java.util.UUID @@ -28,6 +30,12 @@ object MockPresentationService extends Mock[PresentationService] { PresentationError, PresentationRecord ] + object CreateSDJWTPresentationRecord + extends Effect[ + (DidId, DidId, DidCommID, Option[String], Seq[ProofType], ast.Json.Obj, Option[Options]), + PresentationError, + PresentationRecord + ] object CreateAnoncredPresentationRecord extends Effect[ @@ -52,6 +60,9 @@ object MockPresentationService extends Mock[PresentationService] { object AcceptRequestPresentation extends Effect[(DidCommID, Seq[String]), PresentationError, PresentationRecord] + object AcceptSDJWTRequestPresentation + extends Effect[(DidCommID, Seq[String], Option[ast.Json.Obj]), PresentationError, PresentationRecord] + object AcceptAnoncredRequestPresentation extends Effect[ (DidCommID, AnoncredCredentialProofsV1), @@ -90,6 +101,20 @@ object MockPresentationService extends Mock[PresentationService] { (pairwiseVerifierDID, pairwiseProverDID, thid, connectionId, proofTypes, options) ) + override def createSDJWTPresentationRecord( + pairwiseVerifierDID: DidId, + pairwiseProverDID: DidId, + thid: DidCommID, + connectionId: Option[String], + proofTypes: Seq[ProofType], + claimsToDisclose: ast.Json.Obj, + options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options], + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = + proxy( + CreateSDJWTPresentationRecord, + (pairwiseVerifierDID, pairwiseProverDID, thid, connectionId, proofTypes, claimsToDisclose, options) + ) + override def createAnoncredPresentationRecord( pairwiseVerifierDID: DidId, pairwiseProverDID: DidId, @@ -115,6 +140,13 @@ object MockPresentationService extends Mock[PresentationService] { ): IO[PresentationError, PresentationRecord] = proxy(AcceptAnoncredRequestPresentation, (recordId, credentialsToUse)) + def acceptSDJWTRequestPresentation( + recordId: DidCommID, + credentialsToUse: Seq[String], + claimsToDisclose: Option[ast.Json.Obj] + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = + proxy(AcceptSDJWTRequestPresentation, (recordId, credentialsToUse, claimsToDisclose)) + override def rejectRequestPresentation(recordId: DidCommID): IO[PresentationError, PresentationRecord] = proxy(RejectRequestPresentation, recordId) @@ -176,6 +208,17 @@ object MockPresentationService extends Mock[PresentationService] { issuanceDate: Instant ): IO[PresentationError, PresentationPayload] = ??? + override def createSDJwtPresentationPayloadFromRecord( + record: DidCommID, + issuer: Issuer, + ): IO[PresentationError, SdJwtPresentationPayload] = ??? + + def createSDJwtPresentation( + recordId: DidCommID, + requestPresentation: RequestPresentation, + prover: Issuer, + ): ZIO[WalletAccessContext, PresentationError, Presentation] = ??? + override def createAnoncredPresentationPayloadFromRecord( record: DidCommID, anoncredCredentialProof: AnoncredCredentialProofsV1, diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala index 621f38f570..9dcf6a3a06 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationService.scala @@ -7,9 +7,11 @@ import org.hyperledger.identus.pollux.core.model.* import org.hyperledger.identus.pollux.core.model.error.PresentationError import org.hyperledger.identus.pollux.core.model.presentation.* import org.hyperledger.identus.pollux.core.service.serdes.{AnoncredCredentialProofsV1, AnoncredPresentationRequestV1} +import org.hyperledger.identus.pollux.sdjwt.PresentationJson import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.models.WalletAccessContext import zio.* +import zio.json.ast import java.time.Instant import java.util as ju @@ -27,6 +29,16 @@ trait PresentationService { options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options], ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] + def createSDJWTPresentationRecord( + pairwiseVerifierDID: DidId, + pairwiseProverDID: DidId, + thid: DidCommID, + connectionId: Option[String], + proofTypes: Seq[ProofType], + claimsToDisclose: ast.Json.Obj, + options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options], + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] + def createAnoncredPresentationRecord( pairwiseVerifierDID: DidId, pairwiseProverDID: DidId, @@ -45,6 +57,17 @@ trait PresentationService { issuanceDate: Instant ): ZIO[WalletAccessContext, PresentationError, PresentationPayload] + def createSDJwtPresentationPayloadFromRecord( + record: DidCommID, + issuer: Issuer, + ): ZIO[WalletAccessContext, PresentationError, SdJwtPresentationPayload] + + def createSDJwtPresentation( + recordId: DidCommID, + requestPresentation: RequestPresentation, + prover: Issuer, + ): ZIO[WalletAccessContext, PresentationError, Presentation] + def createAnoncredPresentationPayloadFromRecord( record: DidCommID, anoncredCredentialProof: AnoncredCredentialProofsV1, @@ -88,6 +111,12 @@ trait PresentationService { credentialsToUse: Seq[String] ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] + def acceptSDJWTRequestPresentation( + recordId: DidCommID, + credentialsToUse: Seq[String], + claimsToDisclose: Option[ast.Json.Obj] + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] + def acceptAnoncredRequestPresentation( recordId: DidCommID, credentialsToUse: AnoncredCredentialProofsV1 diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala index 0670081021..224f0aac4f 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceImpl.scala @@ -12,7 +12,7 @@ import org.hyperledger.identus.pollux.anoncreds.* import org.hyperledger.identus.pollux.core.model.* import org.hyperledger.identus.pollux.core.model.error.PresentationError import org.hyperledger.identus.pollux.core.model.error.PresentationError.* -import org.hyperledger.identus.pollux.core.model.presentation.* +import org.hyperledger.identus.pollux.core.model.presentation.{SdJwtPresentationPayload, *} import org.hyperledger.identus.pollux.core.model.schema.`type`.anoncred.AnoncredSchemaSerDesV1 import org.hyperledger.identus.pollux.core.repository.{CredentialRepository, PresentationRepository} import org.hyperledger.identus.pollux.core.service.serdes.* @@ -20,6 +20,8 @@ import org.hyperledger.identus.pollux.vc.jwt.* import org.hyperledger.identus.shared.models.WalletAccessContext import org.hyperledger.identus.shared.utils.aspects.CustomMetricsAspect import zio.* +import zio.json.* +import org.hyperledger.identus.pollux.sdjwt.{CredentialJson, PresentationJson, SDJWT} import java.net.URI import java.rmi.UnexpectedException @@ -27,6 +29,7 @@ import java.time.Instant import java.util as ju import java.util.{UUID, Base64 as JBase64} import scala.util.Try +import scala.util.chaining._ private class PresentationServiceImpl( uriDereferencer: URIDereferencer, @@ -93,7 +96,7 @@ private class PresentationServiceImpl( signedCredentials.nonEmpty, signedCredentials, PresentationError.IssuedCredentialNotFoundError( - new Throwable("No matching issued credentials found in prover db") + "No matching issued credentials found in prover db" ) ) ) @@ -106,6 +109,88 @@ private class PresentationServiceImpl( } yield presentationPayload } + override def createSDJwtPresentationPayloadFromRecord( + recordId: DidCommID, + prover: Issuer + ): ZIO[WalletAccessContext, PresentationError, SdJwtPresentationPayload] = { + + for { + maybeRecord <- presentationRepository + .getPresentationRecord(recordId) + .mapError(RepositoryError.apply) + record <- ZIO + .fromOption(maybeRecord) + .mapError(_ => RecordIdNotFound(recordId)) + credentialsToUse <- ZIO + .fromOption(record.credentialsToUse) + .mapError(_ => InvalidFlowStateError(s"No request found for this record: $recordId")) + sdJwtClaimsToDisclose <- ZIO + .fromOption(record.sdJwtClaimsToDisclose) + .mapError(_ => InvalidFlowStateError(s"No request found for this record: $recordId")) + requestPresentation <- ZIO + .fromOption(record.requestPresentationData) + .mapError(_ => InvalidFlowStateError(s"RequestPresentation not found: $recordId")) + issuedValidCredentials <- credentialRepository + .getValidIssuedCredentials(credentialsToUse.map(DidCommID(_))) + .mapError(RepositoryError.apply) + signedCredentials = issuedValidCredentials.flatMap(_.issuedCredentialRaw) + + issuedCredentials <- ZIO.fromEither( + Either.cond( + signedCredentials.nonEmpty, + signedCredentials, + PresentationError.IssuedCredentialNotFoundError( + "No matching issued credentials found in prover db" + ) + ) + ) + + presentationJson <- createSDJwtPresentationPayloadFromCredential( + issuedCredentials, + sdJwtClaimsToDisclose, + requestPresentation, + prover + ) + presentationPayload <- ZIO.succeed( + SdJwtPresentationPayload( + claimsToDisclose = sdJwtClaimsToDisclose, + presentation = presentationJson, + options = None + ) + ) + + } yield presentationPayload + } + + override def createSDJwtPresentation( + recordId: DidCommID, + requestPresentation: RequestPresentation, + prover: Issuer, + ): ZIO[WalletAccessContext, PresentationError, Presentation] = { + for { + presentationPayload <- createSDJwtPresentationPayloadFromRecord(recordId, prover) + presentation <- ZIO.succeed( + Presentation( + body = Presentation.Body( + goal_code = requestPresentation.body.goal_code, + comment = requestPresentation.body.comment + ), + attachments = Seq( + AttachmentDescriptor + .buildBase64Attachment( + payload = presentationPayload.toJson.getBytes, + mediaType = Some(PresentCredentialFormat.SDJWT.name) + ) + ), + thid = requestPresentation.thid.orElse(Some(requestPresentation.id)), + from = requestPresentation.to, + to = requestPresentation.from + ) + ) + } yield presentation + + } + override def createAnoncredPresentationPayloadFromRecord( recordId: DidCommID, anoncredCredentialProof: AnoncredCredentialProofsV1, @@ -133,7 +218,7 @@ private class PresentationServiceImpl( issuedValidCredentials.nonEmpty, issuedValidCredentials, PresentationError.IssuedCredentialNotFoundError( - new Throwable("No matching issued credentials found in prover db") + "No matching issued credentials found in prover db" ) ) ) @@ -230,7 +315,7 @@ private class PresentationServiceImpl( thid: DidCommID, connectionId: Option[String], proofTypes: Seq[ProofType], - maybeOptions: Option[org.hyperledger.identus.pollux.core.model.presentation.Options] + options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options] ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = { createPresentationRecord( pairwiseVerifierDID, @@ -239,7 +324,27 @@ private class PresentationServiceImpl( connectionId, CredentialFormat.JWT, proofTypes, - maybeOptions.map(options => Seq(toJWTAttachment(options))).getOrElse(Seq.empty) + options.map(o => Seq(toJWTAttachment(o))).getOrElse(Seq.empty) + ) + } + + override def createSDJWTPresentationRecord( + pairwiseVerifierDID: DidId, + pairwiseProverDID: DidId, + thid: DidCommID, + connectionId: Option[String], + proofTypes: Seq[ProofType], + claimsToDisclose: ast.Json.Obj, + options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options], + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = { + createPresentationRecord( + pairwiseVerifierDID, + pairwiseProverDID, + thid, + connectionId, + CredentialFormat.SDJWT, + proofTypes, + attachments = options.map(o => Seq(toSDJWTAttachment(o, claimsToDisclose))).getOrElse(Seq.empty) ) } @@ -298,6 +403,8 @@ private class PresentationServiceImpl( credentialsToUse = None, anoncredCredentialsToUseJsonSchemaId = None, anoncredCredentialsToUse = None, + sdJwtClaimsToUseJsonSchemaId = None, + sdJwtClaimsToDisclose = None, metaRetries = maxRetries, metaNextRetry = Some(Instant.now()), metaLastFailure = None, @@ -349,9 +456,12 @@ private class PresentationServiceImpl( case Seq(head) => val jsonF = PresentCredentialRequestFormat.JWT.name // stable identifier val anoncredF = PresentCredentialRequestFormat.Anoncred.name // stable identifier + val sdjwtF = PresentCredentialRequestFormat.SDJWT.name // stable identifier + head.format match - case None => ZIO.fail(PresentationError.MissingCredentialFormat) - case Some(`jsonF`) => ZIO.succeed(CredentialFormat.JWT) + case None => ZIO.fail(PresentationError.MissingCredentialFormat) + case Some(`jsonF`) => ZIO.succeed(CredentialFormat.JWT) + case Some(`sdjwtF`) => ZIO.succeed(CredentialFormat.SDJWT) case Some(`anoncredF`) => head.data match case Base64(data) => @@ -382,6 +492,8 @@ private class PresentationServiceImpl( credentialsToUse = None, anoncredCredentialsToUseJsonSchemaId = None, anoncredCredentialsToUse = None, + sdJwtClaimsToUseJsonSchemaId = None, + sdJwtClaimsToDisclose = None, metaRetries = maxRetries, metaNextRetry = Some(Instant.now()), metaLastFailure = None, @@ -397,6 +509,62 @@ private class PresentationServiceImpl( } yield record } + private def createSDJwtPresentationPayloadFromCredential( + issuedCredentials: Seq[String], + claimsToDisclose: SdJwtCredentialToDisclose, + requestPresentation: RequestPresentation, + prover: Issuer + ): IO[PresentationError, PresentationJson] = { + + val verifiableCredentials: Either[ + PresentationError.PresentationDecodingError, + Seq[CredentialJson] + ] = issuedCredentials.map { signedCredential => + decode[org.hyperledger.identus.mercury.model.Base64](signedCredential) + .flatMap(x => Right(CredentialJson(new String(java.util.Base64.getDecoder.decode(x.base64))))) + .left + .map(err => PresentationDecodingError(s"JsonData decoding error: $err")) + }.sequence + + import io.circe.parser.decode + import io.circe.syntax._ + import java.util.Base64 + + val result: Either[PresentationDecodingError, SDJwtPresentation] = + requestPresentation.attachments.headOption + .map(attachment => + decode[org.hyperledger.identus.mercury.model.Base64](attachment.data.asJson.noSpaces) + .leftMap(err => PresentationDecodingError(s"PresentationAttachment decoding error: $err")) + .flatMap { base64 => + org.hyperledger.identus.pollux.core.service.serdes.SDJwtPresentation.given_JsonDecoder_SDJwtPresentation + .decodeJson(new String(Base64.getUrlDecoder.decode(base64.base64))) + .leftMap(err => PresentationDecodingError(s"SDJwtPresentation decoding error: $err")) + } + ) + .getOrElse(Left(PresentationDecodingError("Error: No attachment found for SDJwtPresentation"))) + + for { + sdJwtPresentation <- ZIO.fromEither(result) + vcs <- ZIO.fromEither(verifiableCredentials) + vc <- ZIO + .fromOption(vcs.headOption) + .orElseFail(MissingCredential) + iss <- ZIO.fromEither(vc.iss).mapError(error => PresentationDecodingError(s"Error: IssuedCredentials $error")) + sub = vc.sub.toOption // optional + iat = vc.iat.toOption // optional + exp <- ZIO.fromEither(vc.exp).mapError(error => PresentationDecodingError(s"Error: IssuedCredentials $error")) + claimsObject <- ZIO + .fromOption(claimsToDisclose.asObject) + .mapError(error => PresentationDecodingError(s"Error: IssuedCredentials claimsToDisclose must be a Json Obj")) + sdJwtClaimsToDisclose = claimsObject + .add("iss", ast.Json.Str(iss)) + .pipe(o => sub.map(sub => o.add("sub", ast.Json.Str(sub))).getOrElse(o)) // optional + .pipe(o => iat.map(iat => o.add("iat", ast.Json.Num(iat))).getOrElse(o)) // optional + .add("exp", ast.Json.Num(exp)) + presentationPayload = SDJWT.createPresentation(vc, sdJwtClaimsToDisclose.toJson) + } yield presentationPayload + } + /** All credentials MUST be of the same format */ private def createJwtPresentationPayloadFromCredential( issuedCredentials: Seq[String], @@ -413,7 +581,7 @@ private class PresentationServiceImpl( .flatMap(x => Right(new String(java.util.Base64.getDecoder.decode(x.base64)))) .flatMap(x => Right(JwtVerifiableCredentialPayload(JWT(x)))) .left - .map(err => PresentationDecodingError(new Throwable(s"JsonData decoding error: $err"))) + .map(err => PresentationDecodingError(s"JsonData decoding error: $err")) }.sequence val maybePresentationOptions @@ -425,11 +593,9 @@ private class PresentationServiceImpl( org.hyperledger.identus.pollux.core.model.presentation.PresentationAttachment.given_Decoder_PresentationAttachment .decodeJson(data.json.asJson) .map(_.options) - .leftMap(err => - PresentationDecodingError(new Throwable(s"PresentationAttachment decoding error: $err")) - ) + .leftMap(err => PresentationDecodingError(s"PresentationAttachment decoding error: $err")) ) - .leftMap(err => PresentationDecodingError(new Throwable(s"JsonData decoding error: $err"))) + .leftMap(err => PresentationDecodingError(s"JsonData decoding error: $err")) ) .getOrElse(Right(None)) @@ -591,7 +757,6 @@ private class PresentationServiceImpl( anoncredCredentialDefinition = AnoncredCredentialDefinition(content) } yield (credentialDefinitionUri, anoncredCredentialDefinition) } - def acceptRequestPresentation( recordId: DidCommID, credentialsToUse: Seq[String] @@ -615,6 +780,35 @@ private class PresentationServiceImpl( record <- fetchPresentationRecord(recordId, count) } yield record } + def acceptSDJWTRequestPresentation( + recordId: DidCommID, + credentialsToUse: Seq[String], + claimsToDisclose: Option[ast.Json.Obj] + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = { + + for { + record <- getRecordWithState(recordId, ProtocolState.RequestReceived) + issuedCredentials <- credentialRepository + .getValidIssuedCredentials(credentialsToUse.map(DidCommID(_))) + .mapError(RepositoryError.apply) + validatedCredentialsFormat <- validateCredentialsFormat(record, issuedCredentials) + _ <- validateCredentials( + s"No matching issued credentials found in prover db from the given: $credentialsToUse", + validatedCredentialsFormat + ) + count <- presentationRepository + .updateSDJWTPresentationWithCredentialsToUse( + recordId, + Option(credentialsToUse), + claimsToDisclose, + ProtocolState.PresentationPending + ) + .mapError(RepositoryError.apply) @@ CustomMetricsAspect.startRecordingTime( + s"${record.id}_present_proof_flow_prover_presentation_pending_to_generated_ms_gauge" + ) + record <- fetchPresentationRecord(recordId, count) + } yield record + } private def fetchPresentationRecord(recordId: DidCommID, count: RuntimeFlags) = { for { @@ -681,9 +875,7 @@ private class PresentationServiceImpl( Either.cond( issuedCredentialRaw.nonEmpty, issuedCredentialRaw, - PresentationError.IssuedCredentialNotFoundError( - new Throwable(errorMessage) - ) + PresentationError.IssuedCredentialNotFoundError(errorMessage) ) ) } yield () @@ -1002,6 +1194,17 @@ private class PresentationServiceImpl( ) } + private[this] def toSDJWTAttachment( + options: Options, + claimsToDsiclose: ast.Json.Obj + ): AttachmentDescriptor = { + AttachmentDescriptor.buildBase64Attachment( + mediaType = Some("application/json"), + format = Some(PresentCredentialRequestFormat.SDJWT.name), + payload = SDJwtPresentation(options, claimsToDsiclose).toJson.getBytes + ) + } + private[this] def toAnoncredAttachment( presentationRequest: AnoncredPresentationRequestV1 ): AttachmentDescriptor = { diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala index 662dec0066..1555e5dd09 100644 --- a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifier.scala @@ -13,10 +13,12 @@ import org.hyperledger.identus.pollux.core.model.error.PresentationError import org.hyperledger.identus.pollux.core.model.presentation.Options import org.hyperledger.identus.pollux.core.model.{DidCommID, PresentationRecord} import org.hyperledger.identus.pollux.core.service.serdes.{AnoncredCredentialProofsV1, AnoncredPresentationRequestV1} +import org.hyperledger.identus.pollux.core.model.presentation.SdJwtPresentationPayload +import org.hyperledger.identus.pollux.sdjwt.PresentationJson import org.hyperledger.identus.pollux.vc.jwt.{Issuer, PresentationPayload, W3cCredentialPayload} import org.hyperledger.identus.shared.models.WalletAccessContext import zio.{IO, URLayer, ZIO, ZLayer} - +import zio.json.* import java.time.Instant import java.util.UUID @@ -46,6 +48,27 @@ class PresentationServiceNotifier( ) ) + override def createSDJWTPresentationRecord( + pairwiseVerifierDID: DidId, + pairwiseProverDID: DidId, + thid: DidCommID, + connectionId: Option[String], + proofTypes: Seq[ProofType], + claimsToDisclose: ast.Json.Obj, + options: Option[org.hyperledger.identus.pollux.core.model.presentation.Options] + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = + notifyOnSuccess( + svc.createSDJWTPresentationRecord( + pairwiseVerifierDID, + pairwiseProverDID, + thid, + connectionId, + proofTypes, + claimsToDisclose, + options, + ) + ) + def createAnoncredPresentationRecord( pairwiseVerifierDID: DidId, pairwiseProverDID: DidId, @@ -137,6 +160,13 @@ class PresentationServiceNotifier( ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = notifyOnSuccess(svc.acceptPresentation(recordId)) + def acceptSDJWTRequestPresentation( + recordId: DidCommID, + credentialsToUse: Seq[String], + claimsToDisclose: Option[ast.Json.Obj] + ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = + notifyOnSuccess(svc.acceptSDJWTRequestPresentation(recordId, credentialsToUse, claimsToDisclose)) + override def rejectPresentation( recordId: DidCommID ): ZIO[WalletAccessContext, PresentationError, PresentationRecord] = @@ -172,6 +202,19 @@ class PresentationServiceNotifier( ): ZIO[WalletAccessContext, PresentationError, PresentationPayload] = svc.createJwtPresentationPayloadFromRecord(record, issuer, issuanceDate) + override def createSDJwtPresentationPayloadFromRecord( + record: DidCommID, + issuer: Issuer + ): ZIO[WalletAccessContext, PresentationError, SdJwtPresentationPayload] = + svc.createSDJwtPresentationPayloadFromRecord(record, issuer) + + override def createSDJwtPresentation( + record: DidCommID, + requestPresentation: RequestPresentation, + issuer: Issuer + ): ZIO[WalletAccessContext, PresentationError, Presentation] = + svc.createSDJwtPresentation(record, requestPresentation, issuer) + override def createAnoncredPresentationPayloadFromRecord( record: DidCommID, anoncredCredentialProof: AnoncredCredentialProofsV1, diff --git a/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/serdes/SDJwtPresentationRequest.scala b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/serdes/SDJwtPresentationRequest.scala new file mode 100644 index 0000000000..5e1c53bc92 --- /dev/null +++ b/pollux/core/src/main/scala/org/hyperledger/identus/pollux/core/service/serdes/SDJwtPresentationRequest.scala @@ -0,0 +1,14 @@ +package org.hyperledger.identus.pollux.core.service.serdes + +import zio.json.* +import org.hyperledger.identus.pollux.core.model.presentation.Options + +case class SDJwtPresentation(options: Options, claims: ast.Json.Obj) + +object SDJwtPresentation { + given JsonDecoder[Options] = DeriveJsonDecoder.gen[Options] + given JsonEncoder[Options] = DeriveJsonEncoder.gen[Options] + + given JsonDecoder[SDJwtPresentation] = DeriveJsonDecoder.gen[SDJwtPresentation] + given JsonEncoder[SDJwtPresentation] = DeriveJsonEncoder.gen[SDJwtPresentation] +} diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositorySpecSuite.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositorySpecSuite.scala index 5b51bf6c10..36b3d92206 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositorySpecSuite.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/repository/PresentationRepositorySpecSuite.scala @@ -33,6 +33,8 @@ object PresentationRepositorySpecSuite { credentialsToUse = None, anoncredCredentialsToUseJsonSchemaId = None, anoncredCredentialsToUse = None, + sdJwtClaimsToUseJsonSchemaId = None, + sdJwtClaimsToDisclose = None, metaRetries = maxRetries, metaNextRetry = Some(Instant.now()), metaLastFailure = None, diff --git a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifierSpec.scala b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifierSpec.scala index adc49fca4c..9aa4e6f7f0 100644 --- a/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifierSpec.scala +++ b/pollux/core/src/test/scala/org/hyperledger/identus/pollux/core/service/PresentationServiceNotifierSpec.scala @@ -36,6 +36,8 @@ object PresentationServiceNotifierSpec extends ZIOSpecDefault with PresentationS None, None, None, + None, + None, 5, None, None diff --git a/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/CrytoUtils.scala b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/CrytoUtils.scala new file mode 100644 index 0000000000..4f89ab6ee1 --- /dev/null +++ b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/CrytoUtils.scala @@ -0,0 +1,23 @@ +package org.hyperledger.identus.pollux.sdjwt + +import java.util.Base64 + +// TODO move to apollo +private[sdjwt] object CrytoUtils { + + def privateKeyToPem(encodedPrivateKey: Array[Byte]): String = { + val base64Encoded = Base64.getEncoder.encodeToString(encodedPrivateKey) + val pemHeader = "-----BEGIN PRIVATE KEY-----" + val pemFooter = "-----END PRIVATE KEY-----" + val pemBody = base64Encoded.grouped(64).mkString("\n") // Split into lines of 64 characters + pemHeader + "\n" + pemBody + "\n" + pemFooter + } + + def publicKeyToPem(encodedPublicKey: Array[Byte]): String = { + val base64Encoded = Base64.getEncoder.encodeToString(encodedPublicKey) + val pemHeader = "-----BEGIN PUBLIC KEY-----" + val pemFooter = "-----END PUBLIC KEY-----" + val pemBody = base64Encoded.grouped(64).mkString("\n") // Split into lines of 64 characters + pemHeader + "\n" + pemBody + "\n" + pemFooter + } +} diff --git a/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/Models.scala b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/Models.scala new file mode 100644 index 0000000000..bf0e7d225c --- /dev/null +++ b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/Models.scala @@ -0,0 +1,122 @@ +package org.hyperledger.identus.pollux.sdjwt + +import sdjwtwrapper.* +import org.hyperledger.identus.shared.crypto.* +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.util.PrivateKeyInfoFactory +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory +import zio.json.* +import java.util.Base64 + +opaque type IssuerPublicKey = String +object IssuerPublicKey { + def fromPem(keyPem: String): IssuerPublicKey = keyPem + + def apply(key: Ed25519PublicKey): IssuerPublicKey = { + val publicKeyParameters = new Ed25519PublicKeyParameters(key.getEncoded, 0) + val publicKeyInfo = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicKeyParameters) + val pkcs8Bytes = publicKeyInfo.getEncoded() + val data = CrytoUtils.publicKeyToPem(pkcs8Bytes) + fromPem(data) + } + extension (pemKey: IssuerPublicKey) + def value: String = pemKey + def pem: String = pemKey +} + +// Note about signAlg supported in json web token 9.2.0 +// HS256 // HMAC using SHA-256 +// HS384 // HMAC using SHA-384 +// HS512 // HMAC using SHA-512 +// ES256 // ECDSA using SHA-256 +// ES384 // ECDSA using SHA-384 +// RS256 // RSASSA-PKCS1-v1_5 using SHA-256 +// RS384 // RSASSA-PKCS1-v1_5 using SHA-384 +// RS512 // RSASSA-PKCS1-v1_5 using SHA-512 +// PS256 // RSASSA-PSS using SHA-256 +// PS384 // RSASSA-PSS using SHA-384 +// PS512 // RSASSA-PSS using SHA-512 +// EdDSA // Edwards-curve Digital Signature Algorithm (EdDSA) + +case class IssuerPrivateKey(value: EncodingKeyValue, signAlg: String) +object IssuerPrivateKey { + + def fromEcPem(keyPem: String): IssuerPrivateKey = + IssuerPrivateKey(EncodingKeyValue.Companion.fromEcPem(keyPem), "ES256") + def fromEdPem(keyPem: String): IssuerPrivateKey = + IssuerPrivateKey(EncodingKeyValue.Companion.fromEdPem(keyPem), "EdDSA") + def apply(key: Ed25519PrivateKey): IssuerPrivateKey = { + val privateKeyParameters = new Ed25519PrivateKeyParameters(key.getEncoded, 0) + val privateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParameters) + val pkcs8Bytes = privateKeyInfo.getEncoded() + val data = CrytoUtils.privateKeyToPem(pkcs8Bytes) + fromEdPem(data) + } + // def apply(key: X25519PrivateKey): IssuerPrivateKey = { + // val privateKeyParameters = new X25519PrivateKeyParameters(key.getEncoded, 0) + // val privateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParameters) + // val pkcs8Bytes = privateKeyInfo.getEncoded() + // EncodingKeyValue.Companion.fromEdPem(Utils.privateKeyToPem(pkcs8Bytes)) + // } +} + +opaque type HolderPublicKey = String +object HolderPublicKey { + def fromJWT(jwtString: String): HolderPublicKey = jwtString + def apply(key: Ed25519PublicKey) = { + val x = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(key.getEncoded) + HolderPublicKey.fromJWT(s"""{"kty":"OKP","crv":"Ed25519","x":"$x"}""") + } + extension (jwtString: HolderPublicKey) + def value: String = jwtString + def jwt: JwkValue = JwkValue.apply(jwtString) +} + +case class HolderPrivateKey(value: EncodingKeyValue, signAlg: String) +object HolderPrivateKey { + + def fromEcPem(keyPem: String): HolderPrivateKey = + HolderPrivateKey(EncodingKeyValue.Companion.fromEcPem(keyPem), "ES256") + def fromEdPem(keyPem: String): HolderPrivateKey = + HolderPrivateKey(EncodingKeyValue.Companion.fromEdPem(keyPem), "EdDSA") + def apply(key: Ed25519PrivateKey): HolderPrivateKey = { + val privateKeyParameters = new Ed25519PrivateKeyParameters(key.getEncoded, 0) + val privateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParameters) + val pkcs8Bytes = privateKeyInfo.getEncoded() + val data = CrytoUtils.privateKeyToPem(pkcs8Bytes) + fromEdPem(data) + } +} + +opaque type CredentialJson = String +object CredentialJson { + given decoder: JsonDecoder[CredentialJson] = JsonDecoder.string.map(CredentialJson(_)) + given encoder: JsonEncoder[CredentialJson] = JsonEncoder.string.contramap[CredentialJson](_.value) + + def apply(value: String): CredentialJson = value + extension (c: CredentialJson) + def value: String = c + def payload: Either[String, String] = ModelsExtensionMethods.payload(c) + def iss: Either[String, String] = ModelsExtensionMethods.iss(c) + def sub: Either[String, String] = ModelsExtensionMethods.sub(c) + def iat: Either[String, BigDecimal] = ModelsExtensionMethods.iat(c) + def exp: Either[String, BigDecimal] = ModelsExtensionMethods.exp(c) + +} + +opaque type PresentationJson = String +object PresentationJson { + given decoder: JsonDecoder[PresentationJson] = JsonDecoder.string.map(PresentationJson(_)) + given encoder: JsonEncoder[PresentationJson] = JsonEncoder.string.contramap[PresentationJson](_.value) + + def apply(value: String): PresentationJson = value + extension (c: PresentationJson) + def value: String = c + def payload: Either[String, String] = ModelsExtensionMethods.payload(c) + def iss: Either[String, String] = ModelsExtensionMethods.iss(c) + def sub: Either[String, String] = ModelsExtensionMethods.sub(c) + def iat: Either[String, BigDecimal] = ModelsExtensionMethods.iat(c) + def exp: Either[String, BigDecimal] = ModelsExtensionMethods.exp(c) + +} diff --git a/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/ModelsExtensionMethods.scala b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/ModelsExtensionMethods.scala new file mode 100644 index 0000000000..65a2c65bd8 --- /dev/null +++ b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/ModelsExtensionMethods.scala @@ -0,0 +1,69 @@ +package org.hyperledger.identus.pollux.sdjwt + +import zio.json.* +import java.util.Base64 + +private[sdjwt] object ModelsExtensionMethods { + extension (c: String) { + private def asJsonObject: Either[String, ast.Json.Obj] = c + .fromJson[ast.Json] + .map(_.asObject) + .flatMap { + case None => Left("PresentationJson must the a Json Object") + case Some(jsonObj) => Right(jsonObj) + } + def payload: Either[String, String] = + asJsonObject.flatMap { + _.get("payload") match + case None => Left("PresentationJson must have the field 'payload'") + case Some(ast.Json.Str(payload)) => + Right( + String( + Base64.getDecoder().decode(payload) // TODO make it safe + ) + ) + case Some(_) => Left("PresentationJson must have the field 'payload' as a Base64 String") + } + private def payloadAsJsonObj: Either[String, zio.json.ast.Json.Obj] = + payload.flatMap { + _.fromJson[ast.Json] + .map(_.asObject) + .flatMap { + case None => Left("The payload in PresentationJson must the a Json Object") + case Some(jsonObj) => Right(jsonObj) + } + } + + def iss: Either[String, String] = + payloadAsJsonObj.flatMap { + _.get("iss") match + case None => Left("The payload in PresentationJson must have the field 'iss'") + case Some(ast.Json.Str(iss)) => Right(iss) + case Some(_) => Left("PresentationJson must have the field 'iss' as a String") + } + + def sub: Either[String, String] = + payloadAsJsonObj.flatMap { + _.get("sub") match + case None => Left("The payload in PresentationJson must have the field 'sub'") + case Some(ast.Json.Str(sub)) => Right(sub) + case Some(_) => Left("PresentationJson must have the field 'sub' as a String") + } + + def iat: Either[String, BigDecimal] = + payloadAsJsonObj.flatMap { + _.get("iat") match + case None => Left("The payload in PresentationJson must have the field 'iat'") + case Some(ast.Json.Num(iat)) => Right(iat) + case Some(_) => Left("PresentationJson must have the field 'iat' as a Num") + } + + def exp: Either[String, BigDecimal] = + payloadAsJsonObj.flatMap { + _.get("exp") match + case None => Left("The payload in PresentationJson must have the field 'exp'") + case Some(ast.Json.Num(exp)) => Right(exp) + case Some(_) => Left("PresentationJson must have the field 'exp' as a Num") + } + } +} diff --git a/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/QueryUtils.scala b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/QueryUtils.scala new file mode 100644 index 0000000000..7d13b8f982 --- /dev/null +++ b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/QueryUtils.scala @@ -0,0 +1,42 @@ +package org.hyperledger.identus.pollux.sdjwt + +import zio.json.* +import zio.json.ast.Json +import zio.json.ast.Json.* +import zio.json.ast.JsonCursor + +private[sdjwt] object QueryUtils { + + type AUX = Bool | Str | Num | Json.Null | None.type + def getCursors(queryJson: Json, cursor: JsonCursor[_, _]): Seq[(JsonCursor[_, ast.Json], AUX)] = { + queryJson match + case Obj(fields) if fields.isEmpty => Seq((cursor, None)) // especial case for SD-JDT lib + case value: Bool => Seq((cursor, value)) + case value: Str => Seq((cursor, value)) + case value: Num => Seq((cursor, value)) + case Json.Null => Seq((cursor, Json.Null)) + case Arr(elements) => + elements.zipWithIndex.flatMap { case (json, index) => + val nextCursor = cursor.isArray.element(index) + getCursors(json, nextCursor) + } + case Obj(fields) => + fields.flatMap { (k, v) => + val nextCursor = cursor.isObject.field(k) + getCursors(v, nextCursor) + } + } + + def testClaims(query: Json, claims: Json) = { + val expectedAux = getCursors(query, JsonCursor.identity) + expectedAux.forall { case (cursor, value) => + value match + case None => claims.get(cursor).isRight // check is the path exists + case Num(v) => claims.get(cursor).map(_.asNumber.exists(_.value == v)).getOrElse(false) + case Str(v) => claims.get(cursor).map(_.asString.exists(_ == v)).getOrElse(false) + case Bool(v) => claims.get(cursor).map(_.asBoolean.exists(_ == v)).getOrElse(false) + case Json.Null => claims.get(cursor).map(_.asNull.isDefined).getOrElse(false) + } + + } +} diff --git a/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/SDJWT.scala b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/SDJWT.scala new file mode 100644 index 0000000000..30cfce2016 --- /dev/null +++ b/pollux/sd-jwt/src/main/scala/org/hyperledger/identus/pollux/sdjwt/SDJWT.scala @@ -0,0 +1,247 @@ +package org.hyperledger.identus.pollux.sdjwt + +import sdjwtwrapper.* +import scala.util.Try +import scala.util.Failure +import scala.util.Success +import zio.json.* +import zio.json.ast.Json + +object SDJWT { + + sealed trait ClaimsValidationResult + sealed trait Valid extends ClaimsValidationResult + case object ValidAnyMatch extends Valid + case class ValidClaims(claims: Json.Obj) extends Valid { + def verifyDiscoseClaims(query: Json.Obj): SDJWT.ValidAnyMatch.type | SDJWT.ClaimsDoNotMatch.type = + if (QueryUtils.testClaims(query, claims)) SDJWT.ValidAnyMatch else SDJWT.ClaimsDoNotMatch + + def verifyDiscoseClaims( + query: Json.Obj, + iss: Option[String], + sub: Option[String], + iat: Option[Long], + exp: Option[Long], + ): SDJWT.ValidAnyMatch.type | SDJWT.ClaimsDoNotMatch.type = { + val fullQuery = Seq( + iss.map("iss" -> Json.Str(_)), + sub.map("sub" -> Json.Str(_)), + iat.map("iat" -> Json.Num(_)), + exp.map("exp" -> Json.Num(_)), + ).flatten.foldLeft(query)((q, v) => q.add(v._1, v._2)) + verifyDiscoseClaims(fullQuery) + } + } + sealed trait Invalid extends ClaimsValidationResult + case object InvalidSignature extends Invalid { def error = "Fail due to invalid input: InvalidSignature" } + case object InvalidClaims extends Invalid { def error = "Fail to Verify the claims" } + case object ClaimsDoNotMatch extends Invalid { def error = "Claims (are valid) but do not match the expected value" } + case object InvalidClaimsIsNotJsonObj extends Invalid { def error = "The claims must be a valid json Obj" } + case class InvalidError(error: String) extends Invalid + + def issueCredential( + issueKey: IssuerPrivateKey, + claims: String, + ): CredentialJson = issueCredential(issueKey, claims, None) + + def issueCredential( + issueKey: IssuerPrivateKey, + claimsMap: Map[String, String], + ): CredentialJson = { + + given encoder: JsonEncoder[String | Int] = new JsonEncoder[String | Int] { + override def unsafeEncode(b: String | Int, indent: Option[Int], out: zio.json.internal.Write): Unit = { + b match { + case obj: String => JsonEncoder.string.unsafeEncode(obj, indent, out) + case obj: Int => JsonEncoder.int.unsafeEncode(obj, indent, out) + } + } + } + + val claims = claimsMap ++ + Map("sub" -> "did:example:holder", "iss" -> "did:example:issuer", "iat" -> 1683000000, "exp" -> 1883000000) + issueCredential(issueKey, claims.toJson, None) + } + + def issueCredential( + issueKey: IssuerPrivateKey, + claims: String, + holderKey: HolderPublicKey, + ): CredentialJson = issueCredential(issueKey, claims, Some(holderKey)) + + private def issueCredential( + issueKey: IssuerPrivateKey, + claims: String, + holderKey: Option[HolderPublicKey] + ): CredentialJson = { + val issuer = new SdjwtIssuerWrapper(issueKey.value, issueKey.signAlg) // null) + val sdjwt = issuer.issueSdJwtAllLevel( + claims, // user_claims + holderKey.map(_.jwt).orNull, // holder_key + false, // add_decoy_claims + SdjwtSerializationFormat.JSON // COMPACT // serialization_format + ) + CredentialJson(sdjwt) + } + + def createPresentation( + sdjwt: CredentialJson, + claimsToDisclose: String, + ): PresentationJson = { + val holder = SdjwtHolderWrapper(sdjwt.value, SdjwtSerializationFormat.JSON) + val presentation = holder.createPresentation( + claimsToDisclose, + null, // nonce + null, // aud + null, // holder_key + null, // signAlg, // sign_alg + ) + PresentationJson(presentation) + } + + /** Create a presentation with challenge + * + * @param sdjwt + * @param claimsToDisclose + * @param nonce + * @param aud + * @param holderKey + * @return + * A presentation + */ + def createPresentation( + sdjwt: CredentialJson, + claimsToDisclose: String, + nonce: String, + aud: String, + holderKey: HolderPrivateKey + ): PresentationJson = { + val holder = SdjwtHolderWrapper(sdjwt.value, SdjwtSerializationFormat.JSON) + val presentation = holder.createPresentation( + claimsToDisclose, + nonce, // nonce + aud, // aud + holderKey.value, // encodingKey("ES256"), // holder_key + holderKey.signAlg, // null, // sign_alg + ) + PresentationJson(presentation) + } + + def getVerifiedClaims( + key: IssuerPublicKey, + presentation: PresentationJson, + claims: String + ): ClaimsValidationResult = { + Try { + val verifier = SdjwtVerifierWrapper( + presentation.value, // sd_jwt_presentation + key.pem, // public_key + null, // expected_aud + null, // expected_nonce + SdjwtSerializationFormat.JSON // serialization_format + ) + verifier.getVerifiedClaims() + } match { + case Failure(ex: SdjwtException.Unspecified) if ex.getMessage() == "invalid input: InvalidSignature" => + InvalidSignature + case Failure(ex) => InvalidError(ex.getMessage()) + case Success(claims) => + claims.fromJson[Json] match + case Left(value) => InvalidClaimsIsNotJsonObj + case Right(json: Json.Obj) => ValidClaims(json) + case Right(json) => InvalidClaimsIsNotJsonObj + + } + } + + def getVerifiedClaims( + key: IssuerPublicKey, + presentation: PresentationJson, + claims: String, + expectedNonce: String, + expectedAud: String, + // holderKey: HolderPrivateKey + ): ClaimsValidationResult = { + Try { + val verifier = SdjwtVerifierWrapper( + presentation.value, // sd_jwt_presentation + key.pem, // public_key + expectedAud, // expected_aud + expectedNonce, // expected_nonce + SdjwtSerializationFormat.JSON // serialization_format + ) + verifier.getVerifiedClaims() + } match { + case Failure(ex: SdjwtException.Unspecified) if ex.getMessage() == "invalid input: InvalidSignature" => + InvalidSignature + case Failure(ex) => InvalidError(ex.getMessage()) + case Success(claims) => + claims.fromJson[Json] match + case Left(value) => InvalidClaimsIsNotJsonObj + case Right(json: Json.Obj) => ValidClaims(json) + case Right(json) => InvalidClaimsIsNotJsonObj + } + } + + @deprecated("use getVerifiedClaims instaded", "ever") + def verifyAndComparePresentation( + key: IssuerPublicKey, + presentation: PresentationJson, + claims: String + ): ClaimsValidationResult = { + Try { + val verifier = SdjwtVerifierWrapper( + presentation.value, // sd_jwt_presentation + key.pem, // public_key + null, // expected_aud + null, // expected_nonce + SdjwtSerializationFormat.JSON // serialization_format + ) + verifier.verify(claims) + } match { + case Failure(ex: SdjwtException.Unspecified) if ex.getMessage() == "invalid input: InvalidSignature" => + InvalidSignature + case Failure(ex) => InvalidError(ex.getMessage()) + case Success(true) => ValidAnyMatch + case Success(false) => InvalidClaims + } + } + + /** Verify Presentation with challenge + * + * @param key + * @param presentation + * @param claims + * @param expectedNonce + * the presentation challenge + * @param expectedAud + * @return + * the result of the verification + */ + @deprecated("use getVerifiedClaims instaded", "ever") + def verifyAndComparePresentation( + key: IssuerPublicKey, + presentation: PresentationJson, + claims: String, + expectedNonce: String, + expectedAud: String, + // holderKey: HolderPrivateKey + ): ClaimsValidationResult = { + Try { + val verifier = SdjwtVerifierWrapper( + presentation.value, // sd_jwt_presentation + key.pem, // public_key + expectedAud, // expected_aud + expectedNonce, // expected_nonce + SdjwtSerializationFormat.JSON // serialization_format + ) + verifier.verify(claims) + } match { + case Failure(ex: SdjwtException.Unspecified) if ex.getMessage() == "invalid input: InvalidSignature" => + InvalidSignature + case Failure(ex) => InvalidError(ex.getMessage()) + case Success(true) => ValidAnyMatch + case Success(false) => InvalidClaims + } + } +} diff --git a/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/SDJWTSpec.scala b/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/SDJWTSpec.scala new file mode 100644 index 0000000000..cfa5d41072 --- /dev/null +++ b/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/SDJWTSpec.scala @@ -0,0 +1,292 @@ +package org.hyperledger.identus.pollux.sdjwt + +import zio.* +import zio.json.* +import zio.test.* +import zio.test.Assertion.* +import org.hyperledger.identus.pollux.sdjwt.* +import org.hyperledger.identus.shared.crypto.* + +def ISSUER_KEY = IssuerPrivateKey.fromEcPem( + """-----BEGIN PRIVATE KEY----- + |MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUr2bNKuBPOrAaxsR + |nbSH6hIhmNTxSGXshDSUD1a1y7ihRANCAARvbx3gzBkyPDz7TQIbjF+ef1IsxUwz + |X1KWpmlVv+421F7+c1sLqGk4HUuoVeN8iOoAcE547pJhUEJyf5Asc6pP + |-----END PRIVATE KEY----- + |""".stripMargin +) +def ISSUER_KEY1 = IssuerPrivateKey.fromEcPem( + """-----BEGIN PRIVATE KEY----- + |kh0+W08hda6NpaytvEeyGdwjPwgJOfXmihEcAvztt5c= + |-----END PRIVATE KEY-----""".stripMargin +) + +def ISSUER_KEY_PUBLIC = IssuerPublicKey.fromPem( + """-----BEGIN PUBLIC KEY----- + |MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb28d4MwZMjw8+00CG4xfnn9SLMVM + |M19SlqZpVb/uNtRe/nNbC6hpOB1LqFXjfIjqAHBOeO6SYVBCcn+QLHOqTw== + |-----END PUBLIC KEY----- + |""".stripMargin +) + +def HOLDER_KEY = HolderPrivateKey.fromEcPem( + """-----BEGIN PRIVATE KEY----- + |MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5K5SCos8zf9zRemG + |GUl6yfok+/NiiryNZsvANWMhF+KhRANCAARMIARHX1m+7c4cXiPhbi99JWgcg/Ug + |uKUOWzu8J4Z6Z2cY4llm2TEBh1VilUOIW0iIq7FX7nnAhOreI0/Rdh2U + |-----END PRIVATE KEY----- + |""".stripMargin +) + +def HOLDER_KEY_JWK_PUBLIC = HolderPublicKey.fromJWT( + """{ + | "kty": "EC", + | "crv": "P-256", + | "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc", + | "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ" + |}""".stripMargin +) + +def CLAIMS = + """{ + | "sub": "did:example:holder", + | "iss": "did:example:issuer", + | "iat": 1683000000, + | "exp": 1883000000, + | "address": { + | "street_address": "Schulstr. 12", + | "locality": "Schulpforta", + | "region": "Sachsen-Anhalt", + | "country": "DE" + | } + |}""".stripMargin + +def CLAIMS_WITHOUT_SUB_IAT = + """{ + | "iss": "did:example:issuer", + | "exp": 1883000000, + | "address": { + | "street_address": "Schulstr. 12", + | "locality": "Schulpforta", + | "region": "Sachsen-Anhalt", + | "country": "DE" + | } + |}""".stripMargin + +def CLAIMS_QUERY = + """{ + | "address": { + | "country": {} + | } + |}""".stripMargin + +def CLAIMS_PRESENTED = + """{ + | "sub": "did:example:holder", + | "iss": "did:example:issuer", + | "iat": 1683000000, + | "exp": 1883000000, + | "address": { + | "region": "Sachsen-Anhalt", + | "country": "DE" + | } + |}""".stripMargin + +def FAlSE_CLAIMS_PRESENTED = + """{ + | "sub": "did:example:holder", + | "iss": "did:example:issuer", + | "iat": 1683000000, + | "exp": 1883000000, + | "address": { + | "region": "Sachsen-Anhalt", + | "country": "PT" + | } + |}""".stripMargin + +object SDJWTSpec extends ZIOSpecDefault { + + override def spec = suite("SDJWTRawSpec")( + test("issue credential") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS, HOLDER_KEY_JWK_PUBLIC) + assertTrue(!credential.value.isEmpty()) + }, + test("make presentation") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_PRESENTED) + assertTrue(!presentation.value.isEmpty()) + }, + test("getVerifiedClaims presentation") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_QUERY) + val ret = SDJWT.getVerifiedClaims(ISSUER_KEY_PUBLIC, presentation, CLAIMS_PRESENTED) + assertTrue( + """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + .map(expected => ret == SDJWT.ValidClaims(expected)) + .getOrElse(false) + ) + }, + test("issue credential without sub & iat and getVerifiedClaims") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS_WITHOUT_SUB_IAT) + val presentation = SDJWT.createPresentation(credential, CLAIMS_QUERY) + val ret = SDJWT.getVerifiedClaims(ISSUER_KEY_PUBLIC, presentation, CLAIMS_PRESENTED) + assertTrue( + """{"iss":"did:example:issuer","exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + .map(expected => ret == SDJWT.ValidClaims(expected)) + .getOrElse(false) + ) + }, + test("verify presentation") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_PRESENTED) + val ret = SDJWT.verifyAndComparePresentation(ISSUER_KEY_PUBLIC, presentation, CLAIMS_PRESENTED) + assertTrue(ret == SDJWT.ValidAnyMatch) + }, + test("fail to verify false claimes presentation") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_PRESENTED) + val ret = SDJWT.verifyAndComparePresentation(ISSUER_KEY_PUBLIC, presentation, FAlSE_CLAIMS_PRESENTED) + assertTrue(ret == SDJWT.InvalidClaims) + }, + + // presentation challenge + test("make presentation with holder presentation challenge") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS, HOLDER_KEY_JWK_PUBLIC) + val presentation = SDJWT.createPresentation( + credential, + CLAIMS_PRESENTED, + "nonce123456789", + "did:example:verifier", + HOLDER_KEY + ) + assert(presentation.value)(isNonEmptyString) + // Assertion { TestArrow.make[PresentationJson, String] { a => TestTrace.succeed(a.value) } >>> isEmptyString.arrow } + }, + test("verify presentation with holder presentation challenge") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS, HOLDER_KEY_JWK_PUBLIC) + val presentation = SDJWT.createPresentation( + credential, + CLAIMS_PRESENTED, + "nonce123456789", + "did:example:verifier", + HOLDER_KEY + ) + val ret = SDJWT.verifyAndComparePresentation( + key = ISSUER_KEY_PUBLIC, + presentation = presentation, + claims = CLAIMS_PRESENTED, + expectedNonce = "nonce123456789", + expectedAud = "did:example:verifier", + ) + assertTrue(ret == SDJWT.ValidAnyMatch) + }, + test("fail to verify presentation with holder presentation challenge") { + val credential = SDJWT.issueCredential(ISSUER_KEY, CLAIMS) + val presentation = SDJWT.createPresentation( + sdjwt = credential, + claimsToDisclose = CLAIMS_PRESENTED, + nonce = "nonce123456789", + aud = "did:example:verifier", + holderKey = HOLDER_KEY + ) + val ret = SDJWT.verifyAndComparePresentation( + ISSUER_KEY_PUBLIC, + presentation, + FAlSE_CLAIMS_PRESENTED, + ) + assertTrue(ret == SDJWT.InvalidClaims) + }, + test("IssuerPrivateKey from a key type ED25519") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + IssuerPrivateKey(ed25519KeyPair.privateKey) // doesn't throw exceptions + IssuerPublicKey(ed25519KeyPair.publicKey) // doesn't throw exceptions + assertTrue(true) + }, + test("HolderPrivateKey from a key type ED25519") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + HolderPrivateKey(ed25519KeyPair.privateKey) // doesn't throw exceptions + assertTrue(true) + }, + test("Flow with key type ED25519 no presentation challenge") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val issuerKey = IssuerPrivateKey(ed25519KeyPair.privateKey) + val issuerPublicKey = IssuerPublicKey(ed25519KeyPair.publicKey) + + val credential = SDJWT.issueCredential(issuerKey, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_PRESENTED) + val ret = SDJWT.verifyAndComparePresentation(issuerPublicKey, presentation, CLAIMS_PRESENTED) + assertTrue(ret == SDJWT.ValidAnyMatch) + }, + test("Flow with key type ED25519 with presentation challenge") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val privateKey = ed25519KeyPair.privateKey + val issuerKey = IssuerPrivateKey(privateKey) + val issuerPublicKey = IssuerPublicKey(ed25519KeyPair.publicKey) + + val holderEd25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val holderKey = HolderPrivateKey(holderEd25519KeyPair.privateKey) + val holderKeyPublic = HolderPublicKey(holderEd25519KeyPair.publicKey) + val credential = SDJWT.issueCredential(issuerKey, CLAIMS, holderKeyPublic) + + val presentation = SDJWT.createPresentation( + sdjwt = credential, + claimsToDisclose = CLAIMS_PRESENTED, + nonce = "nonce123456789", + aud = "did:example:verifier", + holderKey = holderKey + ) + val ret = SDJWT.verifyAndComparePresentation( + key = issuerPublicKey, + presentation = presentation, + claims = CLAIMS_PRESENTED, + expectedNonce = "nonce123456789", + expectedAud = "did:example:verifier" + ) + assertTrue(ret == SDJWT.ValidAnyMatch) + }, + test("Flow with key type ED25519 with presentation challenge fail") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val privateKey = ed25519KeyPair.privateKey + val issuerKey = IssuerPrivateKey(privateKey) + val issuerPublicKey = IssuerPublicKey(ed25519KeyPair.publicKey) + + val holderEd25519KeyPair = KmpEd25519KeyOps.generateKeyPair + // val holderKey = HolderPrivateKey(holderEd25519KeyPair.privateKey) + val holderKeyPublic = HolderPublicKey(holderEd25519KeyPair.publicKey) + val credential = SDJWT.issueCredential(issuerKey, CLAIMS, holderKeyPublic) + + val failHolderEd25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val failHolderKey = HolderPrivateKey(failHolderEd25519KeyPair.privateKey) + + val presentation = SDJWT.createPresentation( + sdjwt = credential, + claimsToDisclose = CLAIMS_PRESENTED, + nonce = "nonce123456789", + aud = "did:example:verifier", + holderKey = failHolderKey + ) + val ret = SDJWT.verifyAndComparePresentation( + key = issuerPublicKey, + presentation = presentation, + claims = CLAIMS_PRESENTED, + expectedNonce = "nonce123456789", + expectedAud = "did:example:verifier" + ) + assertTrue(ret == SDJWT.InvalidSignature) + }, + // methods + test("get iss field from PresentationJson") { + val ed25519KeyPair = KmpEd25519KeyOps.generateKeyPair + val issuerKey = IssuerPrivateKey(ed25519KeyPair.privateKey) + val issuerPublicKey = IssuerPublicKey(ed25519KeyPair.publicKey) + + val credential = SDJWT.issueCredential(issuerKey, CLAIMS) + val presentation = SDJWT.createPresentation(credential, CLAIMS_PRESENTED) + // val ret = SDJWT.verifyPresentation(issuerPublicKey, presentation, CLAIMS_PRESENTED) + assert(presentation.iss)(isRight(equalTo("did:example:issuer"))) + }, + ) + +} diff --git a/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/ValidClaimsSpec.scala b/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/ValidClaimsSpec.scala new file mode 100644 index 0000000000..140f255e1e --- /dev/null +++ b/pollux/sd-jwt/src/test/scala/org/hyperledger/identus/pollux/sdjwt/ValidClaimsSpec.scala @@ -0,0 +1,77 @@ +package org.hyperledger.identus.pollux.sdjwt + +import zio.* +import zio.json.* +import zio.test.* +import zio.test.Assertion.* + +object ValidClaimsSpec extends ZIOSpecDefault { + + override def spec = suite("ValidClaims")( + test("ValidClaims query (empty query)") { + val ret = for { + claims <- """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + expected <- """{}""".fromJson[ast.Json.Obj] + } yield SDJWT.ValidClaims(claims).verifyDiscoseClaims(expected) + assert(ret)(isRight(equalTo(SDJWT.ValidAnyMatch))) + }, + test("ValidClaims query (path exist)") { + val ret = for { + claims <- """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + expected <- + """{ + | "address": { + | "country":{} + | } + |} + """.stripMargin.fromJson[ast.Json.Obj] + } yield SDJWT.ValidClaims(claims).verifyDiscoseClaims(expected) + assert(ret)(isRight(equalTo(SDJWT.ValidAnyMatch))) + }, + test("ValidClaims query (path does not exist)") { + val ret = for { + claims <- """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + expected <- + """{ + | "address": { + | "potatoes":{} + | } + |} + """.stripMargin.fromJson[ast.Json.Obj] + } yield SDJWT.ValidClaims(claims).verifyDiscoseClaims(expected) + assert(ret)(isRight(equalTo(SDJWT.ClaimsDoNotMatch))) + }, + test("ValidClaims query (check value)") { + val ret = for { + claims <- """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + expected <- + """{ + | "address": { + | "country": "DE" + | } + |} + """.stripMargin.fromJson[ast.Json.Obj] + } yield SDJWT.ValidClaims(claims).verifyDiscoseClaims(expected) + assert(ret)(isRight(equalTo(SDJWT.ValidAnyMatch))) + }, + test("ValidClaims query (check fail claim)") { + val ret = for { + claims <- """{"iss":"did:example:issuer","iat":1683000000,"exp":1883000000,"address":{"country":"DE"}}""" + .fromJson[ast.Json.Obj] + expected <- + """{ + | "address": { + | "country": "PT" + | } + |} + """.stripMargin.fromJson[ast.Json.Obj] + } yield SDJWT.ValidClaims(claims).verifyDiscoseClaims(expected) + assert(ret)(isRight(equalTo(SDJWT.ClaimsDoNotMatch))) + }, + ) + +} diff --git a/pollux/sql-doobie/src/main/resources/sql/pollux/V20__add_anoncred_credentials_to_use_columns.sql b/pollux/sql-doobie/src/main/resources/sql/pollux/V20__add_anoncred_credentials_to_use_columns.sql new file mode 100644 index 0000000000..158ab0670a --- /dev/null +++ b/pollux/sql-doobie/src/main/resources/sql/pollux/V20__add_anoncred_credentials_to_use_columns.sql @@ -0,0 +1,4 @@ +-- presentation_records +ALTER TABLE public.presentation_records + ADD COLUMN "sd_jwt_claims_to_use_json_schema_id" VARCHAR(64), + ADD COLUMN "sd_jwt_claims_to_disclose" JSON; \ No newline at end of file diff --git a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala index 1d7ed7c94e..ee52d9fa0a 100644 --- a/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala +++ b/pollux/sql-doobie/src/main/scala/org/hyperledger/identus/pollux/sql/repository/JdbcPresentationRepository.scala @@ -54,6 +54,31 @@ class JdbcPresentationRepository( .transactWallet(xa) } + override def updateSDJWTPresentationWithCredentialsToUse( + recordId: DidCommID, + credentialsToUse: Option[Seq[String]], + sdJwtClaimsToDisclose: Option[SdJwtCredentialToDisclose], + protocolState: ProtocolState + ): RIO[WalletAccessContext, Int] = { + val cxnIO = + sql""" + | UPDATE public.presentation_records + | SET + | credentials_to_use = ${credentialsToUse.map(_.toList)}, + | sd_jwt_claims_to_disclose = $sdJwtClaimsToDisclose, + | protocol_state = $protocolState, + | updated_at = ${Instant.now}, + | meta_retries = $maxRetries, + | meta_next_retry = ${Instant.now}, + | meta_last_failure = null + | WHERE + | id = $recordId + """.stripMargin.update + + cxnIO.run + .transactWallet(xa) + } + def updateAnoncredPresentationWithCredentialsToUse( recordId: DidCommID, anoncredCredentialsToUseJsonSchemaId: Option[String], @@ -92,12 +117,16 @@ class JdbcPresentationRepository( circeJson.noSpaces.fromJson[Json].getOrElse(Json.Null) } - given jsonGet: Get[AnoncredCredentialProofs] = Get[circe.Json].map { jsonString => - circeJsonToZioJson(jsonString) - } - + given jsonGet: Get[AnoncredCredentialProofs] = Get[circe.Json].map { jsonString => circeJsonToZioJson(jsonString) } given jsonPut: Put[AnoncredCredentialProofs] = Put[circe.Json].contramap(zioJsonToCirceJson(_)) + def zioJsonToCirceJsonObj(zioJson: Json.Obj): circe.Json = + parse(zioJson.toString).getOrElse(circe.Json.Null) + def circeJsonToZioJsonObj(circeJson: circe.Json): Json.Obj = + circeJson.noSpaces.fromJson[Json.Obj].getOrElse(Json.Obj()) + given Get[SdJwtCredentialToDisclose] = Get[circe.Json].map { jsonString => circeJsonToZioJsonObj(jsonString) } + given Put[SdJwtCredentialToDisclose] = Put[circe.Json].contramap(zioJsonToCirceJsonObj(_)) + given didCommIDGet: Get[DidCommID] = Get[String].map(DidCommID(_)) given didCommIDPut: Put[DidCommID] = Put[String].contramap(_.value) @@ -143,6 +172,8 @@ class JdbcPresentationRepository( | credentials_to_use, | anoncred_credentials_to_use_json_schema_id, | anoncred_credentials_to_use, + | sd_jwt_claims_to_use_json_schema_id, + | sd_jwt_claims_to_disclose, | meta_retries, | meta_next_retry, | meta_last_failure, @@ -162,6 +193,8 @@ class JdbcPresentationRepository( | ${record.credentialsToUse.map(_.toList)}, | ${record.anoncredCredentialsToUseJsonSchemaId}, | ${record.anoncredCredentialsToUse}, + | ${record.sdJwtClaimsToUseJsonSchemaId}, + | ${record.sdJwtClaimsToDisclose}, | ${record.metaRetries}, | ${record.metaNextRetry}, | ${record.metaLastFailure}, @@ -197,6 +230,8 @@ class JdbcPresentationRepository( | credentials_to_use, | anoncred_credentials_to_use_json_schema_id, | anoncred_credentials_to_use, + | sd_jwt_claims_to_use_json_schema_id, + | sd_jwt_claims_to_disclose, | meta_retries, | meta_next_retry, | meta_last_failure @@ -245,6 +280,8 @@ class JdbcPresentationRepository( | credentials_to_use, | anoncred_credentials_to_use_json_schema_id, | anoncred_credentials_to_use, + | sd_jwt_claims_to_use_json_schema_id, + | sd_jwt_claims_to_disclose, | meta_retries, | meta_next_retry, | meta_last_failure @@ -291,6 +328,8 @@ class JdbcPresentationRepository( | credentials_to_use, | anoncred_credentials_to_use_json_schema_id, | anoncred_credentials_to_use, + | sd_jwt_claims_to_use_json_schema_id, + | sd_jwt_claims_to_disclose, | meta_retries, | meta_next_retry, | meta_last_failure @@ -325,6 +364,8 @@ class JdbcPresentationRepository( | credentials_to_use, | anoncred_credentials_to_use_json_schema_id, | anoncred_credentials_to_use, + | sd_jwt_claims_to_use_json_schema_id, + | sd_jwt_claims_to_disclose, | meta_retries, | meta_next_retry, | meta_last_failure diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/DidJWT.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/DidJWT.scala index 04e3d57c37..e8c3ea3490 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/DidJWT.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/DidJWT.scala @@ -2,13 +2,17 @@ package org.hyperledger.identus.pollux.vc.jwt import com.nimbusds.jose.{JOSEObjectType, JWSAlgorithm, JWSHeader} import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.{ECDSASigner, Ed25519Signer} import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton -import com.nimbusds.jose.jwk.{Curve, ECKey} +import com.nimbusds.jose.jwk.{Curve, ECKey, OctetKeyPair} +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} import io.circe.* +import org.hyperledger.identus.shared.crypto.Ed25519KeyPair import zio.* import java.security.* +import java.util.Base64 opaque type JWT = String @@ -38,7 +42,7 @@ class ES256KSigner(privateKey: PrivateKey) extends Signer { } override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { - EddsaJcs2022ProofGenerator.generateProof(payload, privateKey, pk) + EcdsaJcs2019ProofGenerator.generateProof(payload, privateKey, pk) } override def encode(claim: Json): JWT = { @@ -52,6 +56,31 @@ class ES256KSigner(privateKey: PrivateKey) extends Signer { } } +class EdSigner(ed25519KeyPair: Ed25519KeyPair) extends Signer { + lazy val signer: Ed25519Signer = { + val d = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(ed25519KeyPair.privateKey.getEncoded) + val x = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(ed25519KeyPair.publicKey.getEncoded) + val okpJson = s"""{"kty":"OKP","crv":"Ed25519","d":"$d","x":"$x"}""" + val octetKeyPair = OctetKeyPair.parse(okpJson) + val ed25519Signer = Ed25519Signer(octetKeyPair) + ed25519Signer + } + + override def generateProofForJson(payload: Json, pk: PublicKey): Task[Proof] = { + EddsaJcs2022ProofGenerator.generateProof(payload, ed25519KeyPair) + } + + override def encode(claim: Json): JWT = { + val claimSet = JWTClaimsSet.parse(claim.noSpaces) + val signedJwt = SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.EdDSA).build(), + claimSet + ) + signedJwt.sign(signer) + JWT(signedJwt.serialize()) + } +} + def toJWKFormat(holderJwk: ECKey): JsonWebKey = { JsonWebKey( kty = "EC", diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerification.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerification.scala index fbebe05907..a4013ae812 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerification.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/JWTVerification.scala @@ -1,20 +1,25 @@ package org.hyperledger.identus.pollux.vc.jwt import com.nimbusds.jose.JWSVerifier -import com.nimbusds.jose.crypto.ECDSAVerifier +import com.nimbusds.jose.crypto.{ECDSAVerifier, Ed25519Verifier} import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import com.nimbusds.jose.jwk.* import com.nimbusds.jose.util.Base64URL import com.nimbusds.jwt.SignedJWT import io.circe import io.circe.generic.auto.* +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory import org.hyperledger.identus.castor.core.model.did.VerificationRelationship +import org.hyperledger.identus.shared.crypto.Ed25519PublicKey import pdi.jwt.* import zio.* import zio.prelude.* -import java.security.PublicKey -import java.security.interfaces.ECPublicKey +import java.security.interfaces.{ECPublicKey, EdECPublicKey} +import java.security.spec.X509EncodedKeySpec +import java.security.{KeyFactory, PublicKey} import scala.util.{Failure, Success, Try} object JWTVerification { @@ -22,6 +27,7 @@ object JWTVerification { // https://github.com/decentralized-identity/did-jwt/blob/8b3655097a1382934cabdf774d580e6731a636b1/src/JWT.ts#L146 val SUPPORT_PUBLIC_KEY_TYPES: Map[String, Set[String]] = Map( "ES256K" -> Set("EcdsaSecp256k1VerificationKey2019", "JsonWebKey2020"), + "EdDSA" -> Set("Ed25519VerificationKey2020", "JsonWebKey2020"), // Add support for other key types here ) @@ -122,7 +128,10 @@ object JWTVerification { def toECDSAVerifier(publicKey: PublicKey): JWSVerifier = { val verifier: JWSVerifier = publicKey match { case key: ECPublicKey => ECDSAVerifier(key) - case key => throw Exception(s"unsupported public-key type: ${key.getClass.getCanonicalName}") + case key: EdECPublicKey => + val octetKeyPair = Ed25519PublicKey.toPublicKeyOctetKeyPair(key) + Ed25519Verifier(octetKeyPair) + case key => throw Exception(s"unsupported public-key type: ${key.getClass.getCanonicalName}") } verifier.getJCAContext.setProvider(BouncyCastleProviderSingleton.getInstance) verifier @@ -179,6 +188,7 @@ object JWTVerification { // To be fully compliant, key extraction MUST follow the referenced URI which // might not be in the same DID document. For now, this only performs lookup within // the same DID document which is what Prism DID currently support. + val dereferencedKeysToCheck: Vector[VerificationMethod] = { val (referenced, embedded) = publicKeysToCheck.partitionMap[String, VerificationMethod] { case uri: String => Left(uri) @@ -202,9 +212,19 @@ object JWTVerification { for { publicKeyJwk <- verificationMethod.publicKeyJwk curve <- publicKeyJwk.crv - x <- publicKeyJwk.x.map(Base64URL.from) - y <- publicKeyJwk.y.map(Base64URL.from) - } yield new ECKey.Builder(Curve.parse(curve), x, y).build().toPublicKey + + key <- curve match + case "Ed25519" => + publicKeyJwk.x.map(Base64URL.from).map { base64 => + Ed25519PublicKey.toJavaEd25519PublicKey(base64.decode()) + } + case "secp256k1" => + for { + x <- publicKeyJwk.x.map(Base64URL.from) + y <- publicKeyJwk.y.map(Base64URL.from) + } yield new ECKey.Builder(Curve.parse(curve), x, y).build().toPublicKey + + } yield key Validation.fromOptionWith("Unable to parse Public Key")(maybePublicKey) } } diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala index c27c6999ba..db7daf1127 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/Proof.scala @@ -12,6 +12,7 @@ import scodec.bits.ByteVector import scala.util.Try import java.security.* import java.security.spec.X509EncodedKeySpec +import org.hyperledger.identus.shared.crypto.Ed25519KeyPair sealed trait Proof { val id: Option[String] = None @@ -30,7 +31,7 @@ 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 + Decoder[EcdsaJcs2019Proof].widen // Note: Add another proof types here when available ) @@ -43,9 +44,9 @@ object Proof { } } -object EddsaJcs2022ProofGenerator { +object EcdsaJcs2019ProofGenerator { private val provider = BouncyCastleProviderSingleton.getInstance - def generateProof(payload: Json, sk: PrivateKey, pk: PublicKey): Task[EddsaJcs2022Proof] = { + def generateProof(payload: Json, sk: PrivateKey, pk: PublicKey): Task[EcdsaJcs2019Proof] = { for { canonicalizedJsonString <- ZIO.fromEither(JsonUtils.canonicalizeToJcs(payload.spaces2)) canonicalizedJson <- ZIO.fromEither(parser.parse(canonicalizedJsonString)) @@ -63,7 +64,7 @@ object EddsaJcs2022ProofGenerator { multiKey.asJson.dropNullValues.noSpaces.getBytes, "application/json" ) - } yield EddsaJcs2022Proof( + } yield EcdsaJcs2019Proof( proofValue = base58BtsEncodedSignature, maybeCreated = Some(created), verificationMethod = verificationMethod @@ -113,6 +114,90 @@ object EddsaJcs2022ProofGenerator { verifier.verify(signature) } } + +object EddsaJcs2022ProofGenerator { + private val provider = BouncyCastleProviderSingleton.getInstance + + def generateProof(payload: Json, ed25519KeyPair: Ed25519KeyPair): Task[EddsaJcs2022Proof] = { + for { + canonicalizedJsonString <- ZIO.fromEither(JsonUtils.canonicalizeToJcs(payload.spaces2)) + canonicalizedJson <- ZIO.fromEither(parser.parse(canonicalizedJsonString)) + dataToSign = canonicalizedJson.noSpaces.getBytes + signature = ed25519KeyPair.privateKey.sign(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(ed25519KeyPair.publicKey.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): IO[ParsingFailure, Boolean] = for { + canonicalizedJsonString <- ZIO + .fromEither(JsonUtils.canonicalizeToJcs(payload.spaces2)) + .mapError(ioError => ParsingFailure("Error Parsing canonicalized", ioError)) + canonicalizedJson <- ZIO + .fromEither(parser.parse(canonicalizedJsonString)) + // .mapError(_.getMessage) + dataToVerify = canonicalizedJson.noSpaces.getBytes + signature <- ZIO + .fromEither(MultiBaseString.fromString(proofValue).flatMap(_.getBytes)) + .mapError(error => + // TODO fix RuntimeException + ParsingFailure("Error Parsing MultiBaseString", new RuntimeException("Error Parsing MultiBaseString")) + ) + publicKeyBytes <- ZIO + .fromEither(pk.publicKeyMultibase.toRight("No public key provided inside MultiKey").flatMap(_.getBytes)) + .mapError(error => + // TODO fix RuntimeException + ParsingFailure("Error Parsing MultiBaseString", new RuntimeException("Error Parsing MultiBaseString")) + ) + javaPublicKey <- ZIO + .fromEither(recoverPublicKey(publicKeyBytes)) + .mapError(error => + // TODO fix RuntimeException + ParsingFailure("Error recoverPublicKey", new RuntimeException("Error recoverPublicKey")) + ) + isValid = verify(javaPublicKey, signature, dataToVerify) + } yield isValid + + private def recoverPublicKey(pkBytes: Array[Byte]): Either[String, PublicKey] = { + val keyFactory = KeyFactory.getInstance("Ed25519", 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("Ed25519", provider) + verifier.initVerify(publicKey) + verifier.update(data) + verifier.verify(signature) + } +} +case class EcdsaJcs2019Proof(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 = "ecdsa-jcs-2019" +} + case class EddsaJcs2022Proof(proofValue: String, verificationMethod: String, maybeCreated: Option[Instant]) extends Proof { override val created: Option[Instant] = maybeCreated @@ -121,27 +206,26 @@ case class EddsaJcs2022Proof(proofValue: String, verificationMethod: String, may 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), - ) +object ProofCodecs { + def proofEncoder[T <: Proof](cryptoSuiteValue: String): Encoder[T] = (proof: T) => + 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", Json.fromString(cryptoSuiteValue)), + ("previousProof", proof.previousProof.asJson), + ("nonce", proof.nonce.asJson) + ) - given proofDecoder: Decoder[EddsaJcs2022Proof] = + def proofDecoder[T <: Proof]( + createProof: (String, String, Option[Instant]) => T, + cryptoSuiteValue: String + ): Decoder[T] = (c: HCursor) => for { id <- c.downField("id").as[Option[String]] @@ -155,11 +239,21 @@ object EddsaJcs2022Proof { 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 - ) - } + } yield createProof(proofValue, verificationMethod, created) +} + +object EcdsaJcs2019Proof { + given proofEncoder: Encoder[EcdsaJcs2019Proof] = ProofCodecs.proofEncoder[EcdsaJcs2019Proof]("ecdsa-jcs-2019") + given proofDecoder: Decoder[EcdsaJcs2019Proof] = ProofCodecs.proofDecoder[EcdsaJcs2019Proof]( + (proofValue, verificationMethod, created) => EcdsaJcs2019Proof(proofValue, verificationMethod, created), + "ecdsa-jcs-2019" + ) +} + +object EddsaJcs2022Proof { + given proofEncoder: Encoder[EddsaJcs2022Proof] = ProofCodecs.proofEncoder[EddsaJcs2022Proof]("eddsa-jcs-2022") + given proofDecoder: Decoder[EddsaJcs2022Proof] = ProofCodecs.proofDecoder[EddsaJcs2022Proof]( + (proofValue, verificationMethod, created) => EddsaJcs2022Proof(proofValue, verificationMethod, created), + "eddsa-jcs-2022" + ) } diff --git a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala index 445b707dd7..73d94fb3ed 100644 --- a/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala +++ b/pollux/vc-jwt/src/main/scala/org/hyperledger/identus/pollux/vc/jwt/VerifiableCredentialPayload.scala @@ -19,7 +19,7 @@ import java.time.{Clock, Instant, OffsetDateTime, ZoneId} import scala.util.Try import com.nimbusds.jwt.SignedJWT import scala.util.Failure - +import org.hyperledger.identus.shared.crypto.{PublicKey => ApolloPublicKey} opaque type DID = String object DID { def apply(value: String): DID = value @@ -31,6 +31,8 @@ object DID { case class Issuer(did: DID, signer: Signer, publicKey: PublicKey) +case class ApolloIssuer(did: DID, signer: Signer, publicKey: ApolloPublicKey) + sealed trait VerifiableCredentialPayload case class W3cVerifiableCredentialPayload(payload: W3cCredentialPayload, proof: JwtProof) @@ -667,7 +669,7 @@ object CredentialVerification { // Verify proof verified <- proof match - case EddsaJcs2022Proof(proofValue, verificationMethod, maybeCreated) => + case EcdsaJcs2019Proof(proofValue, verificationMethod, maybeCreated) => val publicKeyMultiBaseEffect = uriResolver .resolve(verificationMethod) .mapError(_.toThrowable) @@ -679,7 +681,7 @@ object CredentialVerification { for { publicKeyMultiBase <- publicKeyMultiBaseEffect statusListCredJsonWithoutProof = vcStatusListCredJson.hcursor.downField("proof").delete.top.get - verified <- EddsaJcs2022ProofGenerator + verified <- EcdsaJcs2019ProofGenerator .verifyProof(statusListCredJsonWithoutProof, proofValue, publicKeyMultiBase) .mapError(_.getMessage) } yield verified @@ -900,7 +902,7 @@ object W3CCredential { for { proof <- issuer.signer.generateProofForJson(jsonCred, issuer.publicKey) jsonProof <- proof match - case a: EddsaJcs2022Proof => ZIO.succeed(a.asJson.dropNullValues) + case a: EcdsaJcs2019Proof => ZIO.succeed(a.asJson.dropNullValues) verifiableCredentialWithProof = jsonCred.deepMerge(Map("proof" -> jsonProof).asJson) } yield verifiableCredentialWithProof diff --git a/shared/core/src/main/scala/org/hyperledger/identus/shared/utils/Json.scala b/shared/core/src/main/scala/org/hyperledger/identus/shared/utils/Json.scala index d7ddbb5670..3fe5ce97c8 100644 --- a/shared/core/src/main/scala/org/hyperledger/identus/shared/utils/Json.scala +++ b/shared/core/src/main/scala/org/hyperledger/identus/shared/utils/Json.scala @@ -1,7 +1,7 @@ package org.hyperledger.identus.shared.utils import org.erdtman.jcs.JsonCanonicalizer -import scala.util.Try +import java.io.IOException object Json { @@ -13,8 +13,7 @@ object Json { * canonicalized JSON string */ - def canonicalizeToJcs(jsonStr: String): Either[Throwable, String] = { - val canonicalizer = Try { new JsonCanonicalizer(jsonStr) } - canonicalizer.map(_.getEncodedString).toEither - } + def canonicalizeToJcs(jsonStr: String): Either[IOException, String] = + try { Right(new JsonCanonicalizer(jsonStr).getEncodedString) } + catch case exception: IOException => Left(exception) } diff --git a/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/Apollo.scala b/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/Apollo.scala index 304eef02a5..15ddfdf3ae 100644 --- a/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/Apollo.scala +++ b/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/Apollo.scala @@ -1,13 +1,19 @@ package org.hyperledger.identus.shared.crypto -import org.hyperledger.identus.shared.models.HexString +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jose.util.Base64URL +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.jce.spec.ECNamedCurveSpec +import org.hyperledger.identus.shared.models.HexString import zio.* -import java.security.KeyFactory -import java.security.spec.{ECPrivateKeySpec, ECPublicKeySpec} +import java.security.interfaces.EdECPublicKey +import java.security.spec.* +import java.security.{KeyFactory, PublicKey} import scala.util.Try trait Apollo { @@ -52,6 +58,8 @@ enum DerivationPath { final case class ECPoint(x: Array[Byte], y: Array[Byte]) +final case class EdECPoint(x: Boolean, y: Array[Byte]) + // secp256k1 final case class Secp256k1KeyPair(publicKey: Secp256k1PublicKey, privateKey: Secp256k1PrivateKey) trait Secp256k1PublicKey extends PublicKey, Verifiable { @@ -120,7 +128,32 @@ trait Secp256k1KeyOps { } // ed25519 -final case class Ed25519KeyPair(publicKey: Ed25519PublicKey, privateKey: Ed25519PrivateKey) +final case class Ed25519KeyPair(publicKey: Ed25519PublicKey, privateKey: Ed25519PrivateKey) { + def toOctetKeyPair: OctetKeyPair = { + val d = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(privateKey.getEncoded) + val x = java.util.Base64.getUrlEncoder.withoutPadding().encodeToString(publicKey.getEncoded) + val okpJson = s"""{"kty":"OKP","crv":"Ed25519","d":"$d","x":"$x"}""" + OctetKeyPair.parse(okpJson) + } +} +object Ed25519PublicKey { + + def toJavaEd25519PublicKey(rawPublicKeyBytes: Array[Byte]): java.security.PublicKey = { + val publicKeyParams = new Ed25519PublicKeyParameters(rawPublicKeyBytes, 0) + val subjectPublicKeyInfo = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicKeyParams) + val publicKeyInfoBytes = subjectPublicKeyInfo.getEncoded + val keyFactory = KeyFactory.getInstance("Ed25519", "BC") + val x509PublicKeySpec = new java.security.spec.X509EncodedKeySpec(publicKeyInfoBytes) + keyFactory.generatePublic(x509PublicKeySpec) + } + def toPublicKeyOctetKeyPair(publicKey: EdECPublicKey): OctetKeyPair = { + val subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded) + val x = Base64URL.encode(subjectPublicKeyInfo.getPublicKeyData.getBytes) + val okpJson = s"""{"kty":"OKP","crv":"Ed25519","x":"$x"}""" + OctetKeyPair.parse(okpJson) + } + +} trait Ed25519PublicKey extends PublicKey, Verifiable { override final def hashCode(): Int = HexString.fromByteArray(getEncoded).hashCode() @@ -129,6 +162,7 @@ trait Ed25519PublicKey extends PublicKey, Verifiable { HexString.fromByteArray(this.getEncoded) == HexString.fromByteArray(otherPK.getEncoded) case _ => false } + } trait Ed25519PrivateKey extends PrivateKey, Signable { type Pub = Ed25519PublicKey diff --git a/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/KmpApollo.scala b/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/KmpApollo.scala index 4d043521fd..8bc1a03f18 100644 --- a/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/KmpApollo.scala +++ b/shared/crypto/src/main/scala/org/hyperledger/identus/shared/crypto/KmpApollo.scala @@ -15,7 +15,7 @@ import io.iohk.atala.prism.apollo.utils.KMMX25519PublicKey import zio.* import scala.jdk.CollectionConverters.* -import scala.util.{Try, Success, Failure} +import scala.util.{Failure, Success, Try} final case class KmpSecp256k1PublicKey(publicKey: KMMECSecp256k1PublicKey) extends Secp256k1PublicKey { @@ -109,6 +109,7 @@ object KmpSecp256k1KeyOps extends Secp256k1KeyOps { } final case class KmpEd25519PublicKey(publicKey: KMMEdPublicKey) extends Ed25519PublicKey { + override def getEncoded: Array[Byte] = publicKey.getRaw() override def verify(data: Array[Byte], signature: Array[Byte]): Try[Unit] = Try(publicKey.verify(data, signature))