From 2cdd8b5d82e7e7f013e263e434a717e919006bae Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Wed, 10 Jun 2026 15:45:23 +0800 Subject: [PATCH 1/5] feat(mediator): add opt-in did:prism support Signed-off-by: Yurii Shynbuiev --- AGENTS.md | 5 +- README.md | 5 + build.sbt | 2 + mediator/src/main/resources/application.conf | 2 + .../identus/mediator/MediatorStandalone.scala | 91 +++++++++++++++---- .../hyperledger/identus/db/AgentStub.scala | 10 +- .../identus/mediator/MediatorConfigSpec.scala | 42 +++++++++ .../hyperledger/identus/mediator/Global.scala | 1 - 8 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 mediator/src/test/scala/org/hyperledger/identus/mediator/MediatorConfigSpec.scala diff --git a/AGENTS.md b/AGENTS.md index b9326af5..4b756283 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ The mediator serves on port 8080 by default (`mediator.server.http.port` in `app | `MONGODB_DB_NAME` | Database name | `mediator` | | `PORT` | HTTP server port override | `8080` | | `ESCALATE_TO` | Problem report escalation email | `atala@iohk.io` | +| `DID_PRISM_RESOLVER` | Base URL for resolving `did:prism` DID documents | Default in `application.conf` | ## MongoDB Dependency @@ -124,7 +125,7 @@ The mediator identity requires two OKP key pairs in JOSE (JWK) format: 3. Generate Ed25519 key: `openssl genpkey -algorithm Ed25519 -out private_key_ed25519.pem` 4. Format to JWK similarly for `d` and `x` fields -The keys are set via environment variables (`KEY_AGREEMENT_D`, `KEY_AGREEMENT_X`, `KEY_AUTHENTICATION_D`, `KEY_AUTHENTICATION_X`). The mediator builds a `did:peer:2` DID from these keys and the service endpoints at startup (see `MediatorStandalone.scala` → `MediatorConfig.did`). +The keys are set via environment variables (`KEY_AGREEMENT_D`, `KEY_AGREEMENT_X`, `KEY_AUTHENTICATION_D`, `KEY_AUTHENTICATION_X`). By default, the mediator builds a `did:peer:2` DID from these keys and the service endpoints at startup. Operators can also provide an explicit DID plus `keyStore` directly in `application.conf`, which enables mediator identities such as `did:prism`. **⚠️ Never use the demo keys from `build.sbt` in production.** @@ -135,7 +136,7 @@ The keys are set via environment variables (`KEY_AGREEMENT_D`, `KEY_AGREEMENT_X` `MediatorStandalone` (`mediator/src/main/scala/.../MediatorStandalone.scala`) — ZIO application that: 1. Loads HOCON config via `zio-config-typesafe` + `zio-config-magnolia` -2. Constructs a `did:peer:2` DID from the configured keys + endpoints +2. Constructs a `did:peer:2` DID from the configured keys + endpoints, or uses an explicitly configured DID + `keyStore` 3. Wires ZIO layers: `ReactiveMongoApi` → repos (`UserAccountRepo`, `MessageItemRepo`, `OutboxMessageRepo`) → `OperatorImp` → protocol handlers 4. Starts ZIO HTTP server on configured port (default 8080) diff --git a/README.md b/README.md index 50100147..f0f33fbd 100644 --- a/README.md +++ b/README.md @@ -180,12 +180,17 @@ To set up the mediator identity: [How to generate mediator identity](./mediator-identity-key-generation.md) +By default, the mediator still builds a `did:peer:2` DID from the configured keys and service endpoints. +Optionally, operators can provide an explicit DID and `keyStore` directly in `application.conf`, which enables using +other DID methods such as `did:prism`. + - `KEY_AGREEMENT_D` - is the key agreement private key (MUST be a X25519 OKP key type). - `KEY_AGREEMENT_X` - is the key agreement public key (MUST be a X25519 OKP key type). - `KEY_AUTHENTICATION_D` - is the key authentication private key (MUST be an Ed25519 OKP key type). - `KEY_AUTHENTICATION_X` - is the key authentication public key (MUST be an Ed25519 OKP key type). - `SERVICE_ENDPOINTS` - is the list of endpoints of the mediator split by ';' where the mediator will listen to incoming DIDComm messages. +- `DID_PRISM_RESOLVER` - overrides the base URL used to resolve `did:prism` DID documents. #### mediator-storage diff --git a/build.sbt b/build.sbt index 04f8e6c6..33e4b5d1 100644 --- a/build.sbt +++ b/build.sbt @@ -50,6 +50,7 @@ lazy val D = new { val scalaDID = Def.setting("app.fmgp" %%% "did" % V.scalaDID) val scalaDID_imp = Def.setting("app.fmgp" %%% "did-imp" % V.scalaDID) val scalaDID_peer = Def.setting("app.fmgp" %%% "did-method-peer" % V.scalaDID) + val scalaDID_prism = Def.setting("app.fmgp" %%% "did-method-prism" % V.scalaDID) val scalaDID_framework = Def.setting("app.fmgp" %%% "did-framework" % V.scalaDID) val scalaDID_protocols = Def.setting("app.fmgp" %%% "did-comm-protocols" % V.scalaDID) @@ -208,6 +209,7 @@ lazy val mediator = project .settings( libraryDependencies += D.scalaDID_imp.value, libraryDependencies += D.scalaDID_peer.value, + libraryDependencies += D.scalaDID_prism.value, libraryDependencies += D.scalaDID_framework.value, libraryDependencies += D.scalaDID_protocols.value, // libraryDependencies += D.zioHttp.value, // also import from scala DID diff --git a/mediator/src/main/resources/application.conf b/mediator/src/main/resources/application.conf index 12b16e31..1cb192c4 100644 --- a/mediator/src/main/resources/application.conf +++ b/mediator/src/main/resources/application.conf @@ -36,4 +36,6 @@ mediator = { } problem.report.escalateTo = "atala@iohk.io" problem.report.escalateTo = ${?ESCALATE_TO} + didPrismResolver = "https://raw.githubusercontent.com/FabioPinheiro/prism-vdr/refs/heads/main/mainnet/diddoc" + didPrismResolver = ${?DID_PRISM_RESOLVER} } diff --git a/mediator/src/main/scala/org/hyperledger/identus/mediator/MediatorStandalone.scala b/mediator/src/main/scala/org/hyperledger/identus/mediator/MediatorStandalone.scala index 1a2129e7..0bfb11f1 100644 --- a/mediator/src/main/scala/org/hyperledger/identus/mediator/MediatorStandalone.scala +++ b/mediator/src/main/scala/org/hyperledger/identus/mediator/MediatorStandalone.scala @@ -9,6 +9,7 @@ import fmgp.did.comm.* import fmgp.did.comm.protocol.* import fmgp.did.framework.TransportFactoryImp import fmgp.did.method.peer.* +import fmgp.did.method.prism.* import org.hyperledger.identus.mediator.db.* import org.hyperledger.identus.mediator.protocols.* import zio.* @@ -49,20 +50,60 @@ object CurveConfig: import CurveConfig.given case class MediatorConfig( - endpoints: String, - keyAgreement: OKPPrivateKeyWithoutKid, - keyAuthentication: OKPPrivateKeyWithoutKid + did: DID, + keyStore: KeyStore ) { - val did = DIDPeer2.makeAgent( - Seq(keyAgreement, keyAuthentication), - endpoints - .split(";") - .toSeq - .map { endpoint => fmgp.util.Base64.encode(s"""{"t":"dm","s":{"uri":"$endpoint","a":["didcomm/v2"]}}""") } - .map(DIDPeerServiceEncodedNew(_)) - ) val agentLayer: ZLayer[Any, Nothing, MediatorAgent] = - ZLayer(MediatorAgent.make(id = did.id, keyStore = did.keyStore)) + ZLayer(MediatorAgent.make(id = did, keyStore = keyStore)) +} + +object MediatorConfig { + + def legacy( + endpoints: String, + keyAgreement: OKPPrivateKeyWithoutKid, + keyAuthentication: OKPPrivateKeyWithoutKid + ): MediatorConfig = { + val agent = DIDPeer2.makeAgent( + Seq(keyAgreement, keyAuthentication), + endpoints + .split(";") + .toSeq + .map { endpoint => fmgp.util.Base64.encode(s"""{"t":"dm","s":{"uri":"$endpoint","a":["didcomm/v2"]}}""") } + .map(DIDPeerServiceEncodedNew(_)) + ) + MediatorConfig(did = agent.id, keyStore = agent.keyStore) + } + + private val didConfig = + Config + .string("did") + .mapOrFail(str => + DIDSubject.either(str) match + case Left(value) => Left(Config.Error.InvalidData(Chunk("did"), "Fail to parse the DID: " + value.error)) + case Right(value) => Right(value.toDID: DID) + ) + + private val keyStoreConfig = + Config + .Sequence( + Config.Fallback[PrivateKeyWithKid](Config.derived[OKPPrivateKeyWithKid], Config.derived[ECPrivateKeyWithKid]) + ) + .map(keys => KeyStore(keys.toSet)) + .nested("keyStore") + + private val explicitConfig = + (didConfig ++ keyStoreConfig).map((did, keyStore) => MediatorConfig(did = did, keyStore = keyStore)) + + private val legacyConfig = + ( + Config.string("endpoints") ++ + Config.derived[OKPPrivateKeyWithoutKid].nested("keyAgreement") ++ + Config.derived[OKPPrivateKeyWithoutKid].nested("keyAuthentication") + ).map((endpoints, keyAgreement, keyAuthentication) => legacy(endpoints, keyAgreement, keyAuthentication)) + + val config: Config[MediatorConfig] = + Config.Fallback(explicitConfig, legacyConfig) } case class DataBaseConfig( @@ -99,6 +140,18 @@ object MediatorStandalone extends ZIOAppDefault { override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j(mediatorColorFormat) + private def resolverLayer(didPrismResolverBaseUrl: String): ZLayer[Client & Scope, Nothing, Resolver] = + ( + DidPeerResolver.layerDidPeerResolver ++ + (HttpUtils.layer >>> DIDPrismResolver.layerDIDPrismResolver(didPrismResolverBaseUrl)) + ) >>> + ZLayer.fromZIO( + for { + peer <- ZIO.service[DidPeerResolver] + prism <- ZIO.service[DIDPrismResolver] + } yield MultiFallbackResolver(peer, prism): Resolver + ) + def mainProgram = for { _ <- Console.printLine( // https://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow&t=Mediator """███╗ ███╗███████╗██████╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗ @@ -111,11 +164,11 @@ object MediatorStandalone extends ZIOAppDefault { |Visit: https://github.com/hyperledger-identus/mediator""".stripMargin ) configs = ConfigProvider.fromResourcePath() - mediatorConfig <- configs.nested("identity").nested("mediator").load(deriveConfig[MediatorConfig]) + mediatorConfig <- configs.nested("identity").nested("mediator").load(MediatorConfig.config) agentLayer = mediatorConfig.agentLayer _ <- ZIO.log(s"Identus Mediator APP. See https://github.com/hyperledger-identus/mediator") _ <- ZIO.log(s"MediatorConfig: $mediatorConfig") - _ <- ZIO.log(s"DID: ${mediatorConfig.did.id.string}") + _ <- ZIO.log(s"DID: ${mediatorConfig.did.string}") mediatorDbConfig <- configs.nested("database").nested("mediator").load(deriveConfig[DataBaseConfig]) _ <- ZIO.log(s"MediatorDb Connection String: ${mediatorDbConfig.displayConnectionString}") port <- configs @@ -130,12 +183,18 @@ object MediatorStandalone extends ZIOAppDefault { .nested("mediator") .load(Config.string("escalateTo")) _ <- ZIO.log(s"Problem reports escalated to: $escalateTo") - transportFactory = Scope.default >>> (Client.default >>> TransportFactoryImp.layer) + didPrismResolverBaseUrl <- configs + .nested("mediator") + .load(Config.string("didPrismResolver")) + _ <- ZIO.log(s"DID PRISM resolver: $didPrismResolverBaseUrl") + httpClient = Scope.default ++ Client.default + transportFactory = httpClient >>> TransportFactoryImp.layer + resolver = httpClient >>> resolverLayer(didPrismResolverBaseUrl) mongo = AsyncDriverResource.layer >>> ReactiveMongoApi.layer(mediatorDbConfig.finalConnectionString) repos = mongo >>> (MessageItemRepo.layer ++ UserAccountRepo.layer ++ OutboxMessageRepo.layer) myServer <- Server .serve((MediatorAgent.didCommApp ++ DIDCommRoutes.app) @@ (Middleware.cors)) - .provideSomeLayer(DidPeerResolver.layerDidPeerResolver) + .provideSomeLayer(resolver) .provideSomeLayer(agentLayer) .provideSomeLayer(repos) .provideSomeLayer(Scope.default >>> ((agentLayer ++ transportFactory ++ repos) >>> OperatorImp.layer)) diff --git a/mediator/src/test/scala/org/hyperledger/identus/db/AgentStub.scala b/mediator/src/test/scala/org/hyperledger/identus/db/AgentStub.scala index 79a1a674..4972ac0f 100644 --- a/mediator/src/test/scala/org/hyperledger/identus/db/AgentStub.scala +++ b/mediator/src/test/scala/org/hyperledger/identus/db/AgentStub.scala @@ -17,10 +17,12 @@ object AgentStub { OKPPrivateKeyWithoutKid(kty = KTY.OKP, crv = Curve.Ed25519, d = d, x = x) val endpoints = "http://localhost:8080" - val mediatorConfig = MediatorConfig( - endpoints, - keyAgreement("Z6D8LduZgZ6LnrOHPrMTS6uU2u5Btsrk1SGs4fn8M7c", "Sr4SkIskjN_VdKTn0zkjYbhGTWArdUNE4j_DmUpnQGw"), - keyAuthentication("INXCnxFEl0atLIIQYruHzGd5sUivMRyQOzu87qVerug", "MBjnXZxkMcoQVVL21hahWAw43RuAG-i64ipbeKKqwoA") + val mediatorConfig = MediatorConfig.legacy( + endpoints = endpoints, + keyAgreement = + keyAgreement("Z6D8LduZgZ6LnrOHPrMTS6uU2u5Btsrk1SGs4fn8M7c", "Sr4SkIskjN_VdKTn0zkjYbhGTWArdUNE4j_DmUpnQGw"), + keyAuthentication = + keyAuthentication("INXCnxFEl0atLIIQYruHzGd5sUivMRyQOzu87qVerug", "MBjnXZxkMcoQVVL21hahWAw43RuAG-i64ipbeKKqwoA") ) val endpointBob = "http://localhost:8081" diff --git a/mediator/src/test/scala/org/hyperledger/identus/mediator/MediatorConfigSpec.scala b/mediator/src/test/scala/org/hyperledger/identus/mediator/MediatorConfigSpec.scala new file mode 100644 index 00000000..ce88a9a9 --- /dev/null +++ b/mediator/src/test/scala/org/hyperledger/identus/mediator/MediatorConfigSpec.scala @@ -0,0 +1,42 @@ +package org.hyperledger.identus.mediator + +import fmgp.crypto.KeyStore +import fmgp.did.DIDSubject +import org.hyperledger.identus.mediator.db.AgentStub +import zio.test.* + +object MediatorConfigSpec extends ZIOSpecDefault { + + private val legacyKeyAgreement = + AgentStub.keyAgreement("Z6D8LduZgZ6LnrOHPrMTS6uU2u5Btsrk1SGs4fn8M7c", "Sr4SkIskjN_VdKTn0zkjYbhGTWArdUNE4j_DmUpnQGw") + + private val legacyKeyAuthentication = + AgentStub.keyAuthentication("INXCnxFEl0atLIIQYruHzGd5sUivMRyQOzu87qVerug", "MBjnXZxkMcoQVVL21hahWAw43RuAG-i64ipbeKKqwoA") + + override def spec = suite("MediatorConfigSpec")( + test("legacy configuration still generates a did:peer mediator identity") { + val config = MediatorConfig.legacy( + endpoints = "http://localhost:8080;ws://localhost:8080/ws", + keyAgreement = legacyKeyAgreement, + keyAuthentication = legacyKeyAuthentication + ) + + assertTrue(config.did.string.startsWith("did:peer:2.")) && + assertTrue(config.keyStore.keys.size == 2) + }, + test("explicit configuration accepts a did:prism identity with an operator-supplied keystore") { + val did = DIDSubject(s"did:prism:${"a" * 64}").toDID + val keyStore = KeyStore( + Set( + legacyKeyAgreement.withKid(s"${did.string}#key-1"), + legacyKeyAuthentication.withKid(s"${did.string}#key-2"), + ) + ) + + val config = MediatorConfig(did = did, keyStore = keyStore) + + assertTrue(config.did.string == did.string) && + assertTrue(config.keyStore.keys.size == 2) + } + ) +} diff --git a/webapp/src/main/scala/org/hyperledger/identus/mediator/Global.scala b/webapp/src/main/scala/org/hyperledger/identus/mediator/Global.scala index 4a7563ee..6d9e8099 100644 --- a/webapp/src/main/scala/org/hyperledger/identus/mediator/Global.scala +++ b/webapp/src/main/scala/org/hyperledger/identus/mediator/Global.scala @@ -4,7 +4,6 @@ import com.raquo.laminar.api.L.* import fmgp.did.* import fmgp.did.comm.* import fmgp.did.comm.TO -import fmgp.did.method.peer.DIDPeer import org.scalajs.dom import scala.scalajs.js From 74b55ff58e5224c76651b5d6ce30376a79d0fde4 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Thu, 11 Jun 2026 16:27:49 +0800 Subject: [PATCH 2/5] test(mediator): add PRISM DID e2e coverage Signed-off-by: Yurii Shynbuiev --- .github/workflows/prism-integration-tests.yml | 94 +++ build.sbt | 18 + .../mediator/AgentExecutorMediator.scala | 37 +- .../identus/mediator/protocols/Problems.scala | 17 + mediator/src/test/protobuf/prism-ssi.proto | 132 ++++ .../src/test/protobuf/prism-storage.proto | 69 ++ .../src/test/protobuf/prism-version.proto | 28 + mediator/src/test/protobuf/prism.proto | 64 ++ .../mediator/prism/MediatorPrismE2ESpec.scala | 623 ++++++++++++++++++ project/plugins.sbt | 4 +- 10 files changed, 1083 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/prism-integration-tests.yml create mode 100644 mediator/src/test/protobuf/prism-ssi.proto create mode 100644 mediator/src/test/protobuf/prism-storage.proto create mode 100644 mediator/src/test/protobuf/prism-version.proto create mode 100644 mediator/src/test/protobuf/prism.proto create mode 100644 mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala diff --git a/.github/workflows/prism-integration-tests.yml b/.github/workflows/prism-integration-tests.yml new file mode 100644 index 00000000..4d46bf47 --- /dev/null +++ b/.github/workflows/prism-integration-tests.yml @@ -0,0 +1,94 @@ +name: PRISM Integration Tests + +concurrency: + group: ${{ github.head_ref }}${{ github.ref }}-prism-integration-tests + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - "main" + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + prism-integration: + name: Run PRISM integration tests + runs-on: ubuntu-latest + permissions: + contents: read + env: + NEOPRISM_VERSION: "0.14.1" + NEOPRISM_BASE_URL: "http://127.0.0.1:18081" + MEDIATOR_PRISM_MONGO_URI: "mongodb://127.0.0.1:27018/messages" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout mediator + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Java and Scala + uses: olafurpg/setup-scala@32ffa16635ff8f19cc21ea253a987f0fdf29844c # v14 + with: + java-version: openjdk@1.17 + + - name: Start NeoPRISM dev node + run: | + docker run --rm -d \ + --name neoprism \ + -p 18081:8080 \ + -e NPRISM_DB_URL=sqlite::memory: \ + hyperledgeridentus/identus-neoprism:${NEOPRISM_VERSION} dev + + - name: Start MongoDB + run: | + docker run --rm -d \ + --name mediator-prism-mongo \ + -p 27018:27017 \ + mongo:7 + + - name: Wait for NeoPRISM + run: | + for i in {1..60}; do + if curl --fail --silent "${NEOPRISM_BASE_URL}/api/_system/health" > /dev/null; then + exit 0 + fi + sleep 1 + done + echo "NeoPRISM failed to become healthy" >&2 + docker logs neoprism || true + exit 1 + + - name: Wait for MongoDB + run: | + for i in {1..60}; do + if docker exec mediator-prism-mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' | grep -qx '1'; then + exit 0 + fi + sleep 1 + done + echo "MongoDB failed to become healthy" >&2 + docker logs mediator-prism-mongo || true + exit 1 + + - name: Run mediator PRISM integration test + run: | + sbt -mem 2048 -J-Xmx5120m "mediator/testOnly org.hyperledger.identus.mediator.prism.MediatorPrismE2ESpec" + + - name: Show NeoPRISM logs on failure + if: failure() + run: docker logs neoprism || true + + - name: Show MongoDB logs on failure + if: failure() + run: docker logs mediator-prism-mongo || true diff --git a/build.sbt b/build.sbt index 33e4b5d1..a1f2bbd4 100644 --- a/build.sbt +++ b/build.sbt @@ -36,6 +36,7 @@ lazy val V = new { val zioTest = "2.1.26" val zioTestSbt = "2.1.26" val zioTestMagnolia = "2.1.26" + val apollo = "1.8.4-kt2.1.20" // For WEBAPP val laminar = "17.2.1" @@ -86,6 +87,15 @@ lazy val D = new { val zioTest = Def.setting("dev.zio" %% "zio-test" % V.zioTest % Test) val zioTestSbt = Def.setting("dev.zio" %% "zio-test-sbt" % V.zioTestSbt % Test) val zioTestMagnolia = Def.setting("dev.zio" %% "zio-test-magnolia" % V.zioTestMagnolia % Test) + val scalaPbRuntime = + Def.setting("com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % Test) + val apolloJvm = Def.setting( + ("org.hyperledger.identus" % "apollo-jvm" % V.apollo exclude ( + "net.jcip", + "jcip-annotations" + )) % Test + ) + val jcip = Def.setting("com.github.stephenc.jcip" % "jcip-annotations" % "1.0-1" % Test) // For WEBAPP val laminar = Def.setting("com.raquo" %%% "laminar" % V.laminar) @@ -207,6 +217,11 @@ lazy val mediator = project ) .settings((setupTestConfig): _*) .settings( + Test / scalacOptions ~= (_.filterNot(_ == "-Xfatal-warnings")), + Test / PB.protoSources += baseDirectory.value / "src" / "test" / "protobuf", + Test / PB.targets := Seq( + scalapb.gen() -> (Test / sourceManaged).value / "scalapb" + ), libraryDependencies += D.scalaDID_imp.value, libraryDependencies += D.scalaDID_peer.value, libraryDependencies += D.scalaDID_prism.value, @@ -230,6 +245,9 @@ lazy val mediator = project D.zioTest.value, D.zioTestSbt.value, D.zioTestMagnolia.value, + D.scalaPbRuntime.value, + D.apolloJvm.value, + D.jcip.value, ), testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") ) diff --git a/mediator/src/main/scala/org/hyperledger/identus/mediator/AgentExecutorMediator.scala b/mediator/src/main/scala/org/hyperledger/identus/mediator/AgentExecutorMediator.scala index a78bcd2b..a60e3721 100644 --- a/mediator/src/main/scala/org/hyperledger/identus/mediator/AgentExecutorMediator.scala +++ b/mediator/src/main/scala/org/hyperledger/identus/mediator/AgentExecutorMediator.scala @@ -210,7 +210,7 @@ case class AgentExecutorMediator( } .tapError(ex => ZIO.logError(s"Error when execute Protocol: $ex")) } yield goodAction - ret <- action match + ret <- (action match case NoReply => ZIO.unit // TODO Maybe infor transport of immediately reply/close case reply: AnyReply => import fmgp.did.comm.Operations._ @@ -252,7 +252,42 @@ case class AgentExecutorMediator( case Some(ReturnRoute.all) | Some(ReturnRoute.thread) => transport.send(message) } } yield () + ).catchSome { case MediatorDidError(didFail) => + handleResolverFailure(pMsgOrProblemReport, transport, didFail) + } } yield () + + private def handleResolverFailure( + pMsgOrProblemReport: Either[ProblemReport, PlaintextMessage], + transport: TransportDIDComm[Any], + didFail: DidFail + ): ZIO[Agent & Operations & Resolver, Nothing, Unit] = + import fmgp.did.comm.Operations._ + pMsgOrProblemReport match + case Left(problemReport) => + ZIO.logWarning(s"Resolver failure while replying with problem-report: $didFail") *> + sign(problemReport.toPlaintextMessage) + .flatMap(transport.send) + .tapError(error => ZIO.logError(s"Unable to sign fallback problem-report: $error")) + .ignore + case Right(plaintextMessage) => + for { + agent <- ZIO.service[Agent] + fallbackProblem = Problems.resolutionError( + to = plaintextMessage.from.toSet.map(_.asTO), + from = agent.id.asFROM, + pthid = plaintextMessage.id, + piuri = plaintextMessage.`type`, + comment = s"Unable to resolve DID while handling message: $didFail" + ) + _ <- ZIO.logWarning( + s"Resolver failure while handling '${plaintextMessage.`type`}' for thid '${plaintextMessage.id}': $didFail" + ) + _ <- sign(fallbackProblem.toPlaintextMessage) + .flatMap(transport.send) + .tapError(error => ZIO.logError(s"Unable to sign fallback resolver problem-report: $error")) + .ignore + } yield () } object AgentExecutorMediator { diff --git a/mediator/src/main/scala/org/hyperledger/identus/mediator/protocols/Problems.scala b/mediator/src/main/scala/org/hyperledger/identus/mediator/protocols/Problems.scala index ab89cf87..3e8867de 100644 --- a/mediator/src/main/scala/org/hyperledger/identus/mediator/protocols/Problems.scala +++ b/mediator/src/main/scala/org/hyperledger/identus/mediator/protocols/Problems.scala @@ -153,4 +153,21 @@ object Problems { escalate_to = email, ) + def resolutionError( + to: Set[TO], + from: FROM, + pthid: MsgID, + piuri: PIURI, + comment: String + ) = ProblemReport( + to = to, + from = from, + pthid = pthid, + ack = None, + code = ProblemCode.ErroFail("me", "res", "resolver"), + comment = Some(comment), + args = None, + escalate_to = email, + ) + } diff --git a/mediator/src/test/protobuf/prism-ssi.proto b/mediator/src/test/protobuf/prism-ssi.proto new file mode 100644 index 00000000..4d01abe4 --- /dev/null +++ b/mediator/src/test/protobuf/prism-ssi.proto @@ -0,0 +1,132 @@ +syntax = "proto3"; + +package proto; + +// The operation to create a public DID. +message ProtoCreateDID { + DIDCreationData did_data = 1; // DIDCreationData with public keys and services + + // The data necessary to create a DID. + message DIDCreationData { + reserved 1; // Removed DID id field which is empty on creation + repeated PublicKey public_keys = 2; // The keys that belong to this DID Document. + repeated Service services = 3; // The list of services that belong to this DID Document. + repeated string context = 4; // The list of @context values to consider on JSON-LD representations + } +} + +// Specifies the necessary data to update a public DID. +message ProtoUpdateDID { + bytes previous_operation_hash = 1; // The hash of the most recent operation that was used to create or update the DID. + string id = 2; // @exclude TODO: To be redefined after we start using this operation. + repeated UpdateDIDAction actions = 3; // The actual updates to perform on the DID. +} + +message ProtoDeactivateDID { + bytes previous_operation_hash = 1; // The hash of the most recent operation that was used to create or update the DID. + string id = 2; // DID Suffix of the DID to be deactivated +} + +// ########## + +/** + * Represents a public key with metadata, necessary for a DID document. + */ +message PublicKey { + reserved 3, 4, 5, 6; + string id = 1; // The key identifier within the DID Document. + KeyUsage usage = 2; // The key's purpose. + + // The key's representation. + oneof key_data { + ECKeyData ec_key_data = 8; // The Elliptic Curve (EC) key. + CompressedECKeyData compressed_ec_key_data = 9; // Compressed Elliptic Curve (EC) key. + }; +} + +// Every key has a single purpose: +enum KeyUsage { + // UNKNOWN_KEY is an invalid value - Protobuf uses 0 if no value is provided and we want the user to explicitly choose the usage. + UNKNOWN_KEY = 0; + MASTER_KEY = 1; + ISSUING_KEY = 2; + KEY_AGREEMENT_KEY = 3; + AUTHENTICATION_KEY = 4; + REVOCATION_KEY = 5; + CAPABILITY_INVOCATION_KEY = 6; + CAPABILITY_DELEGATION_KEY = 7; + + + // !!!!!!!!!!!!!!!!!!!!!! + VDR_KEY = 8; // Create, Update, Remove - VDR entries. This key does not appear in the document. +} + +/** + * Holds the necessary data to recover an Elliptic Curve (EC)'s public key. + */ + message ECKeyData { + string curve = 1; // The curve name, like secp256k1. + bytes x = 2; // The x coordinate, represented as bytes. + bytes y = 3; // The y coordinate, represented as bytes. +} + +/** + * Holds the compressed representation of data needed to recover Elliptic Curve (EC)'s public key. + */ +message CompressedECKeyData { + string curve = 1; // The curve name, like secp256k1. + bytes data = 2; // compressed Elliptic Curve (EC) public key data. +} + +// ########## + +message Service { + string id = 1; + string type = 2; + string service_endpoint = 3; +} + +// ########## + +// The potential details that can be updated in a DID. +message UpdateDIDAction { + + // The action to perform. + oneof action { + AddKeyAction add_key = 1; // Used to add a new key to the DID. + RemoveKeyAction remove_key = 2; // Used to remove a key from the DID. + AddServiceAction add_service = 3; // Used to add a new service to a DID, + RemoveServiceAction remove_service = 4; // Used to remove an existing service from a DID, + UpdateServiceAction update_service = 5; // Used to Update a list of service endpoints of a given service on a given DID. + PatchContextAction patch_context = 6; // Used to Update a list of `@context` strings used during resolution for a given DID. + } +} + + +// The necessary data to add a key to a DID. +message AddKeyAction { + PublicKey key = 1; // The key to include. +} + +// The necessary data to remove a key from a DID. +message RemoveKeyAction { + string keyId = 1; // the key id to remove +} + +message AddServiceAction { + Service service = 1; +} + +message RemoveServiceAction { + string serviceId = 1; +} + +message UpdateServiceAction { + string serviceId = 1; // scoped to the did, unique per did + string type = 2; // new type if provided + string service_endpoints = 3; +} + +message PatchContextAction { + repeated string context = 1; // The list of strings to use by resolvers during resolution when producing a JSON-LD output +} diff --git a/mediator/src/test/protobuf/prism-storage.proto b/mediator/src/test/protobuf/prism-storage.proto new file mode 100644 index 00000000..cecff0f4 --- /dev/null +++ b/mediator/src/test/protobuf/prism-storage.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package proto; + +/** StorageEventCreateEntry + * To be valid, this operation needs to be signed by an issuing key of the DID: + * - 1) The issuing key need to be valid at the Event/Operation momment + * - 2) The DID needs not to be Deactivate + */ +message ProtoCreateStorageEntry { + reserved 2; // Only used by ProtoUpdateStorageEntry & ProtoDeactivateStorageEntry + reserved 3 to 49; // Those field will be used for validation the Storage Events in the future + bytes did_prism_hash = 1; // The specificId of the did:prism. + bytes nonce = 50; // Used to generate different reference hash (to make different entries with the same initial data possible) + oneof data { + // Nothing // The data field can be missing representing ANY type + bytes bytes = 100; + string ipfs = 101; // CID + // string ipns = ??; // https://docs.ipfs.tech/concepts/ipns/ + StatusListEntry statusListEntry = 102; + } +} + +/** StorageEventUpdateEntry + * To be valid, this operation needs to be signed by an issuing key of the DID: + * - 1) The issuing key need to be valid at the Event/Operation momment + * - 2) The DID needs not to be Deactivate + */ +message ProtoUpdateStorageEntry { + reserved 1, 50; // Only used by ProtoCreateStorageEntry + reserved 3 to 49; // Those field will be used for validation the Storage Events in the future + bytes previous_event_hash = 2; // The hash of the most recent event that was used to create or update the VDR Entry. + oneof data { // The data field can be missing + // Nothing // The data field can be missing representing ANY type + bytes bytes = 100; // Replace the bytes + string ipfs = 101; // Update/replace the data with a CID to IPFS. This is static data + StatusListEntry statusListEntry = 102; // compliments the previous state with just the change (similar to a diff) + } +} + +message ProtoDeactivateStorageEntry{ + reserved 1, 50; // Only used by ProtoCreateStorageEntry + reserved 3 to 49; // Those field will be used for validation the Storage Events in the future + bytes previous_event_hash = 2; // The hash of the most recent event that was used to create or update the VDR Entry. +} + +// ****************** +// *** DATA TYPES *** +// ****************** + +/** TODO WIP Status List entry + * + * This is to be inspired on the following specs (Token Status List & BitstringStatusList): + * - Token Status List: + * - https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/: + * - https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/06/ + * - BitstringStatusList: + * - https://www.w3.org/TR/vc-bitstring-status-list/#bitstringstatuslist + * - https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/10/ + */ +message StatusListEntry { + int64 state = 1; + string name = 2; // optional + string details = 3; // optional + + // uint32 listSize = 1; + // uint32 statusSize = 2; + // bytes intStatus = 3; +} diff --git a/mediator/src/test/protobuf/prism-version.proto b/mediator/src/test/protobuf/prism-version.proto new file mode 100644 index 00000000..ad06199d --- /dev/null +++ b/mediator/src/test/protobuf/prism-version.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package proto; + +// Specifies the protocol version update +message ProtoProtocolVersionUpdate { + string proposer_did = 1; // The DID suffix that proposes the protocol update. + ProtocolVersionInfo version = 2; // Information of the new version +} + +message ProtocolVersion { + // Represent the major version + int32 major_version = 1; + // Represent the minor version + int32 minor_version = 2; +} + +message ProtocolVersionInfo { + reserved 2, 3; + string version_name = 1; // (optional) name of the version + int32 effective_since = 4; // Cardano block number that tells since which block the update is enforced + + // New major and minor version to be announced, + // If major value changes, the node MUST stop issuing and reading events/operations, and upgrade before `effective_since` because the new protocol version. + // If minor value changes, the node can opt to not update. All events _published_ by this node would be also + // understood by other nodes with the same major version. However, there may be new events that this node won't _read_ + ProtocolVersion protocol_version = 5; +} diff --git a/mediator/src/test/protobuf/prism.proto b/mediator/src/test/protobuf/prism.proto new file mode 100644 index 00000000..825c4598 --- /dev/null +++ b/mediator/src/test/protobuf/prism.proto @@ -0,0 +1,64 @@ +syntax = "proto3"; + +package proto; + +import "prism-version.proto"; +import "prism-ssi.proto"; +import "prism-storage.proto"; + +/** + * Wraps an PrismBlock and its metadata. + */ +message PrismObject { + reserved 1, 2, 3; + reserved "block_hash"; + reserved "block_operation_count"; // Number of operations in the block. + reserved "block_byte_length"; // Byte length of the block. + + PrismBlock block_content = 4; // The block content. +} + +/** + * Represent a block that holds evetns/operations. + */ + message PrismBlock { + reserved 1; // Represents the version of the block. Deprecated + repeated SignedPrismOperation operations = 2; // A signed operation, necessary to post anything on the blockchain. + } + +// A signed operation, necessary to post anything on the blockchain. +message SignedPrismOperation { + string signed_with = 1; // The key ID used to sign the operation, it must belong to the DID that signs the operation. + bytes signature = 2; // The actual signature. + PrismOperation operation = 3; // The operation that was signed. +} + + +// The possible events/operations affecting the blockchain. +message PrismOperation { + // https://github.com/input-output-hk/atala-prism-sdk/blob/master/protosLib/src/main/proto/node_models.proto + reserved 3, 4; // fields used by an extension of the protocol. Not relevant for the DID method + // The actual operation. + oneof operation { + // Used to create a public DID. + ProtoCreateDID create_did = 1; + + // Used to update an existing public DID. + ProtoUpdateDID update_did = 2; + + // Used to announce new protocol update + ProtoProtocolVersionUpdate protocol_version_update = 5; + + // Used to deactivate DID + ProtoDeactivateDID deactivate_did = 6; + + // Used to create a public storage entry. + ProtoCreateStorageEntry create_storage_entry = 7; + + // Used to update a storage entry. + ProtoUpdateStorageEntry update_storage_entry = 8; + + // Used to deactivate a storage entry. + ProtoDeactivateStorageEntry deactivate_storage_entry = 9; + }; +} diff --git a/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala b/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala new file mode 100644 index 00000000..9bd47779 --- /dev/null +++ b/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala @@ -0,0 +1,623 @@ +package org.hyperledger.identus.mediator.prism + +import com.google.protobuf.ByteString +import fmgp.crypto.* +import fmgp.did.* +import fmgp.did.comm.Operations +import fmgp.did.comm.Operations.* +import fmgp.did.comm.protocol.pickup3.MessageDelivery +import fmgp.did.comm.protocol.reportproblem2.ProblemReport +import fmgp.did.comm.protocol.reportproblem2.toProblemReport +import fmgp.did.comm.{Attachment, EncryptedMessage, MediaTypes, Message, PlaintextMessage, SignedMessage} +import fmgp.did.comm.layerOperations +import fmgp.did.framework.TransportFactoryImp +import fmgp.did.method.peer.DidPeerResolver +import fmgp.did.method.prism.{DIDPrismResolver, HttpUtils} +import fmgp.crypto.error.DidFail +import org.hyperledger.identus.apollo.derivation.HDKey +import org.hyperledger.identus.apollo.utils.{KMMEdKeyPair, KMMX25519KeyPair} +import org.hyperledger.identus.mediator.* +import org.hyperledger.identus.mediator.db.* +import org.hyperledger.identus.mediator.db.AgentStub +import proto.prism.{PrismOperation, SignedPrismOperation} +import proto.prism_ssi.{CompressedECKeyData, KeyUsage, ProtoCreateDID, PublicKey, Service} +import reactivemongo.api.bson.BSONDocument +import reactivemongo.api.indexes.{Index, IndexType} +import zio.* +import zio.http.* +import zio.json.* +import zio.test.* +import zio.test.Assertion.* + +import java.security.MessageDigest +import java.util.Base64 + +object MediatorPrismE2ESpec extends ZIOSpecDefault { + + private val neoprismBaseUrl = sys.env.getOrElse("NEOPRISM_BASE_URL", "http://127.0.0.1:18081") + private val neoprismResolverBaseUrl = s"$neoprismBaseUrl/api/dids" + private val mongoConnectionString = + sys.env.getOrElse("MEDIATOR_PRISM_MONGO_URI", "mongodb://127.0.0.1:27018/messages") + private val mediatorPort = sys.env.get("MEDIATOR_PRISM_PORT").flatMap(_.toIntOption).getOrElse(18080) + private val mediatorEndpoint = s"http://127.0.0.1:$mediatorPort" + private val mongoLayer = AsyncDriverResource.layer + >>> ReactiveMongoApi.layer(mongoConnectionString) + >>> (UserAccountRepo.layer ++ MessageItemRepo.layer ++ OutboxMessageRepo.layer) + private val clientResolverLayer = ZLayer.make[Client & Resolver]( + Client.default, + Scope.default, + resolverLayer(neoprismResolverBaseUrl) + ) + + private case class SubmitSignedOperationsRequest(signed_operations: Seq[String]) + private object SubmitSignedOperationsRequest { + given JsonCodec[SubmitSignedOperationsRequest] = DeriveJsonCodec.gen[SubmitSignedOperationsRequest] + } + + private case class PrismIdentity( + shortForm: String, + longForm: String, + createOperation: PrismOperation, + signedOperation: SignedPrismOperation, + agent: TestAgent + ) + + private case class PrismIdentityOptions( + serviceEndpoint: Option[String] = None, + includeAuthenticationKey: Boolean = true, + includeKeyAgreementKey: Boolean = true + ) + + private case class TestAgent( + id: DID, + keyStore: KeyStore + ) extends Agent + + private val aliasIndex = Index( + key = Seq("alias" -> IndexType.Ascending), + name = Some("alias_did"), + unique = true, + background = true, + partialFilter = Some(BSONDocument("alias.0" -> BSONDocument("$exists" -> true))) + ) + + override def spec = + suite("MediatorPrismE2ESpec")( + test("resolves PRISM long-form and short-form DIDs through NeoPRISM") { + ZIO.scoped { + for { + _ <- waitForNeoPrism + mediatorIdentity <- makeIdentity(PrismIdentityOptions(serviceEndpoint = Some(mediatorEndpoint))) + senderIdentity <- makeIdentity() + recipientIdentity <- makeIdentity() + mediatorLongDocument <- resolveDidDocument(mediatorIdentity.longForm) + senderLongDocument <- resolveDidDocument(senderIdentity.longForm) + recipientLongDocument <- resolveDidDocument(recipientIdentity.longForm) + _ <- publishAll(mediatorIdentity, senderIdentity, recipientIdentity) + mediatorShortDocument <- resolveDidDocument(mediatorIdentity.shortForm) + senderShortDocument <- resolveDidDocument(senderIdentity.shortForm) + recipientShortDocument <- resolveDidDocument(recipientIdentity.shortForm) + } yield assertTrue( + documentJsonContains(mediatorLongDocument, mediatorIdentity.shortForm, mediatorEndpoint, "#auth-0", "#comm-0"), + documentJsonContains(senderLongDocument, senderIdentity.shortForm, "#auth-0", "#comm-0"), + documentJsonContains(recipientLongDocument, recipientIdentity.shortForm, "#auth-0", "#comm-0"), + documentJsonContains(mediatorShortDocument, mediatorIdentity.shortForm, mediatorEndpoint, "#auth-0", "#comm-0"), + documentJsonContains(senderShortDocument, senderIdentity.shortForm, "#auth-0", "#comm-0"), + documentJsonContains(recipientShortDocument, recipientIdentity.shortForm, "#auth-0", "#comm-0") + ) + } + }, + test("mediates a basic DIDComm message using published PRISM DIDs") { + ZIO.scoped { + for { + _ <- waitForNeoPrism + mediatorIdentity <- makeIdentity(PrismIdentityOptions(serviceEndpoint = Some(mediatorEndpoint))) + senderIdentity <- makeIdentity() + recipientIdentity <- makeIdentity() + _ <- publishAll(mediatorIdentity, senderIdentity, recipientIdentity) + _ <- startMediatorServer(mediatorIdentity.agent) + mediateGrant <- sendExpectReply( + recipientIdentity.agent, + plaintextMediationRequestMessage(recipientIdentity.shortForm, mediatorIdentity.shortForm) + ) + _ <- ZIO.fail(new RuntimeException(s"Expected mediate-grant, got ${mediateGrant.`type`}")) + .unless(mediateGrant.`type`.value == "https://didcomm.org/coordinate-mediation/2.0/mediate-grant") + keylistResponse <- sendExpectReply( + recipientIdentity.agent, + plaintextKeyListUpdateRequestMessage( + recipientIdentity.shortForm, + mediatorIdentity.shortForm, + recipientIdentity.shortForm + ) + ) + _ <- ZIO.fail(new RuntimeException(s"Expected keylist-update-response, got ${keylistResponse.`type`}")) + .unless( + keylistResponse.`type`.value == "https://didcomm.org/coordinate-mediation/2.0/keylist-update-response" + ) + basicMessage = plainTextBasicMessage(senderIdentity.shortForm, recipientIdentity.shortForm) + encryptedBasic <- authEncrypt(basicMessage) + .provideSomeLayer(ZLayer.succeed(senderIdentity.agent)) + .mapError(didFailAsThrowable) + forwardMessage = plaintextForwardMessage( + senderIdentity.shortForm, + recipientIdentity.shortForm, + mediatorIdentity.shortForm, + encryptedBasic.toJson + ) + _ <- sendWithoutReply(senderIdentity.agent, forwardMessage) + delivery <- sendExpectReply( + recipientIdentity.agent, + plaintextDeliveryRequestMessage( + recipientIdentity.shortForm, + mediatorIdentity.shortForm, + recipientIdentity.shortForm + ) + ) + _ <- ZIO.fail(new RuntimeException(s"Expected message-delivery, got ${delivery.`type`}")) + .unless(delivery.`type`.value == MessageDelivery.piuri.value) + attached <- attachmentAsEncryptedMessage(delivery.attachments.toSeq.flatten.headOption) + decryptedMessage <- decrypt(attached) + .provideSomeLayer(ZLayer.succeed(recipientIdentity.agent)) + .mapError(didFailAsThrowable) + decrypted <- decryptedMessage match + case plaintext: PlaintextMessage => ZIO.succeed(plaintext) + case other => + ZIO.fail(new RuntimeException(s"Expected plaintext attachment, got ${other.getClass.getSimpleName}")) + _ <- ZIO.fail(new RuntimeException(s"Expected basicmessage, got ${decrypted.`type`}")) + .unless(decrypted.`type`.value == "https://didcomm.org/basicmessage/2.0/message") + _ <- ZIO.fail(new RuntimeException(s"Expected sender ${senderIdentity.shortForm}, got ${decrypted.from}")) + .unless(decrypted.from.contains(senderIdentity.agent.id.asFROM)) + _ <- ZIO.fail(new RuntimeException(s"Expected recipient ${recipientIdentity.shortForm}, got ${decrypted.to}")) + .unless(decrypted.to.exists(_.contains(recipientIdentity.agent.id.asTO))) + content <- ZIO.fromEither( + decrypted.body.toJson.fromJson[Map[String, String]] + .flatMap(_.get("content").toRight("Missing body.content")) + ) + } yield assertTrue(content == "Hello Alice!") + } + }, + test("fails DIDComm auth encryption when the recipient PRISM DID has no key-agreement key") { + ZIO.scoped { + for { + _ <- waitForNeoPrism + senderIdentity <- makeIdentity() + recipientIdentity <- makeIdentity(PrismIdentityOptions(includeKeyAgreementKey = false)) + _ <- publishAll(senderIdentity, recipientIdentity) + result <- authEncrypt(plainTextBasicMessage(senderIdentity.shortForm, recipientIdentity.shortForm)) + .provideSomeLayer(ZLayer.succeed(senderIdentity.agent)) + .either + } yield assertTrue(result.isLeft) + } + }, + test("still auth encrypts when the sender PRISM DID has no authentication key") { + ZIO.scoped { + for { + _ <- waitForNeoPrism + senderIdentity <- makeIdentity(PrismIdentityOptions(includeAuthenticationKey = false)) + recipientIdentity <- makeIdentity() + _ <- publishAll(senderIdentity, recipientIdentity) + result <- authEncrypt(plainTextBasicMessage(senderIdentity.shortForm, recipientIdentity.shortForm)) + .provideSomeLayer(ZLayer.succeed(senderIdentity.agent)) + .either + } yield assertTrue(result.isRight) + } + }, + test("returns a DIDComm problem-report when the PRISM resolver is misconfigured") { + ZIO.scoped { + for { + _ <- waitForNeoPrism + senderIdentity <- makeIdentity() + legacyMediator = TestAgent(id = AgentStub.mediatorConfig.did, keyStore = AgentStub.mediatorConfig.keyStore) + _ <- publishAll(senderIdentity) + _ <- startMediatorServer(legacyMediator, didPrismResolverBaseUrl = "http://127.0.0.1:1/api/dids") + request = plaintextMediationRequestMessage(senderIdentity.shortForm, legacyMediator.id.string) + encryptedRequest <- authEncrypt(request) + .provideSomeLayer(ZLayer.succeed(senderIdentity.agent)) + .mapError(didFailAsThrowable) + response <- postDidCommMessage(encryptedRequest) + problemReport: ProblemReport <- response match + case signed: SignedMessage => + ZIO + .fromEither(signed.payloadAsPlaintextMessage) + .mapError(didFailAsThrowable) + .flatMap(pmsg => + ZIO + .fromOption(pmsg.toProblemReport.toOption) + .orElseFail(new RuntimeException(s"Expected problem-report payload, got ${pmsg.`type`}")) + ) + case encrypted: EncryptedMessage => + ZIO + .fail(new RuntimeException(s"Expected signed fallback problem-report, got ${encrypted.getClass.getSimpleName}")) + case plaintext: PlaintextMessage => + ZIO + .fromOption(plaintext.toProblemReport.toOption) + .orElseFail(new RuntimeException(s"Expected problem-report payload, got ${plaintext.`type`}")) + comment <- ZIO.fromOption(problemReport.comment).orElseFail(new RuntimeException("Missing problem-report comment")) + } yield assertTrue( + problemReport.piuri == ProblemReport.piuri, + comment.contains("Unable to resolve DID") || comment.contains("Fail to decrypt Message") + ) + } + } + ) + .provideSomeLayerShared(clientResolverLayer) + .provideSomeLayerShared(Operations.layerOperations) + .provideLayerShared(mongoLayer) + @@ TestAspect.sequential + @@ TestAspect.withLiveClock + @@ TestAspect.timeout(2.minutes) + + private def resolverLayer(didPrismResolverBaseUrl: String): ZLayer[Client & Scope, Nothing, Resolver] = + ( + DidPeerResolver.layerDidPeerResolver ++ + (HttpUtils.layer >>> DIDPrismResolver.layerDIDPrismResolver(didPrismResolverBaseUrl)) + ) >>> + ZLayer.fromZIO( + for { + peer <- ZIO.service[DidPeerResolver] + prism <- ZIO.service[DIDPrismResolver] + } yield MultiFallbackResolver(peer, prism): Resolver + ) + + private def makeIdentity(options: PrismIdentityOptions = PrismIdentityOptions()): UIO[PrismIdentity] = + for { + seedChunk <- Random.nextBytes(64) + seed = seedChunk.toArray + master = HDKey(seed, 0, 0).derive("m/0'/1'/0'") + masterPrivate = master.getKMMSecp256k1PrivateKey() + authPair = KMMEdKeyPair.Companion.generateKeyPair() + authPrivateBytes = authPair.getPrivateKey().getRaw() + authPublicBytes = authPair.getPublicKey().getRaw() + agreementPair = KMMX25519KeyPair.Companion.generateKeyPair() + agreementPrivateBytes = agreementPair.getPrivateKey().getRaw() + agreementPublicBytes = agreementPair.getPublicKey().getRaw() + publicKeys = Seq( + Some( + compressedPublicKey( + id = "master-0", + usage = KeyUsage.MASTER_KEY, + curve = "secp256k1", + data = masterPrivate.getPublicKey().getCompressed() + ) + ), + Option.when(options.includeAuthenticationKey)( + compressedPublicKey( + id = "auth-0", + usage = KeyUsage.AUTHENTICATION_KEY, + curve = "Ed25519", + data = authPublicBytes + ) + ), + Option.when(options.includeKeyAgreementKey)( + compressedPublicKey( + id = "comm-0", + usage = KeyUsage.KEY_AGREEMENT_KEY, + curve = "X25519", + data = agreementPublicBytes + ) + ) + ).flatten + createDid = ProtoCreateDID( + didData = Some( + ProtoCreateDID.DIDCreationData( + publicKeys = publicKeys, + services = options.serviceEndpoint.toSeq.map(endpoint => + Service( + id = "didcomm-1", + `type` = "DIDCommMessaging", + serviceEndpoint = didCommServiceEndpointJson(endpoint) + ) + ) + ) + ) + ) + operation = PrismOperation( + operation = PrismOperation.Operation.CreateDid(createDid) + ) + operationBytes = operation.toByteArray + shortForm = s"did:prism:${sha256Hex(operationBytes)}" + longForm = s"$shortForm:${base64UrlNoPad(operationBytes)}" + signedOperation = SignedPrismOperation( + signedWith = "master-0", + signature = ByteString.copyFrom(masterPrivate.sign(operationBytes)), + operation = Some(operation) + ) + authKey = Option.when(options.includeAuthenticationKey)( + OKPPrivateKey( + kty = KTY.OKP, + crv = Curve.Ed25519, + d = base64UrlNoPad(authPrivateBytes), + x = base64UrlNoPad(authPublicBytes), + kid = s"$shortForm#auth-0" + ) + ) + agreementKey = Option.when(options.includeKeyAgreementKey)( + OKPPrivateKey( + kty = KTY.OKP, + crv = Curve.X25519, + d = base64UrlNoPad(agreementPrivateBytes), + x = base64UrlNoPad(agreementPublicBytes), + kid = s"$shortForm#comm-0" + ) + ) + } yield PrismIdentity( + shortForm = shortForm, + longForm = longForm, + createOperation = operation, + signedOperation = signedOperation, + agent = TestAgent(id = DIDSubject(shortForm).toDID, keyStore = KeyStore(Set(authKey, agreementKey).flatten)) + ) + + private def compressedPublicKey(id: String, usage: KeyUsage, curve: String, data: Array[Byte]): PublicKey = + PublicKey( + id = id, + usage = usage, + keyData = PublicKey.KeyData.CompressedEcKeyData( + CompressedECKeyData( + curve = curve, + data = ByteString.copyFrom(data) + ) + ) + ) + + private def didCommServiceEndpointJson(endpoint: String): String = + s"""{"uri":"$endpoint","accept":["didcomm/v2"]}""" + + private def publishAll(identities: PrismIdentity*): ZIO[Client, Throwable, Unit] = + submitSignedOperations(identities.map(_.signedOperation)) *> + ZIO.foreachDiscard(identities)(identity => waitForResolution(identity.shortForm)) + + private def submitSignedOperations(operations: Seq[SignedPrismOperation]): ZIO[Client, Throwable, Unit] = + for { + url <- ZIO.fromEither(URL.decode(s"$neoprismBaseUrl/api/submissions/signed-operations")) + .mapError(new RuntimeException(_)) + request = Request + .post( + url = url, + body = Body.fromString( + SubmitSignedOperationsRequest( + signed_operations = operations.map(op => hex(op.toByteArray)) + ).toJson + ) + ) + .addHeader(Header.ContentType(MediaType.application.json)) + response <- Client.batched(request) + body <- response.body.asString + _ <- ZIO.fail(new RuntimeException(s"NeoPRISM submission failed: ${response.status.code} $body")) + .unless(response.status == Status.Ok) + } yield () + + private def waitForNeoPrism: ZIO[Client, Throwable, Unit] = + waitForHttpStatus(s"$neoprismBaseUrl/api/_system/health", Status.Ok, 60.seconds) + + private def waitForResolution(did: String): ZIO[Client, Throwable, Unit] = + waitForHttpStatus(s"$neoprismBaseUrl/api/dids/$did", Status.Ok, 30.seconds) + + private def assertResolves(did: String): ZIO[Client, Throwable, Unit] = + resolveDidDocument(did).unit + + private def documentJsonContains(document: DIDDocument, expectedFragments: String*): Boolean = { + val json = document.toJson + expectedFragments.forall(json.contains) + } + + private def resolveDidDocument(did: String): ZIO[Client, Throwable, DIDDocument] = + for { + url <- ZIO.fromEither(URL.decode(s"$neoprismBaseUrl/api/dids/$did")) + .mapError(new RuntimeException(_)) + response <- Client.batched(Request.get(url)) + body <- response.body.asString + _ <- ZIO.fail(new RuntimeException(s"Failed to resolve DID $did: ${response.status.code} $body")) + .unless(response.status == Status.Ok) + document <- ZIO.fromEither(body.fromJson[DIDDocument]).mapError(new RuntimeException(_)) + } yield document + + private def waitForHttpStatus(urlValue: String, expected: Status, timeout: Duration): ZIO[Client, Throwable, Unit] = + ZIO + .fromEither(URL.decode(urlValue)) + .mapError(new RuntimeException(_)) + .flatMap(url => + Client + .batched(Request.get(url)) + .flatMap(response => + if (response.status == expected) ZIO.unit + else ZIO.fail(new RuntimeException(s"Unexpected status ${response.status} for $urlValue")) + ) + ) + .retry(Schedule.spaced(1.second)) + .timeoutFail(new RuntimeException(s"Timed out waiting for $urlValue to become $expected"))(timeout) + + private def startMediatorServer( + identity: TestAgent, + didPrismResolverBaseUrl: String = neoprismResolverBaseUrl + ): ZIO[Client & Scope, Throwable, Unit] = { + val agentLayer = ZLayer.succeed(MediatorAgent(identity.id, identity.keyStore)) + val httpClient = Scope.default ++ Client.default + val transportFactory = httpClient >>> TransportFactoryImp.layer + val resolver = httpClient >>> resolverLayer(didPrismResolverBaseUrl) + Server + .serve((MediatorAgent.didCommApp ++ DIDCommRoutes.app) @@ Middleware.cors) + .provideSomeLayer(resolver) + .provideSomeLayer(agentLayer) + .provideSomeLayer(mongoLayer) + .provideSomeLayer(Scope.default >>> ((agentLayer ++ transportFactory ++ mongoLayer) >>> OperatorImp.layer)) + .provideSomeLayer(Operations.layerOperations) + .provide(Server.defaultWithPort(mediatorPort)) + .forkScoped + .unit <* + waitForHttpStatus(s"$mediatorEndpoint/health", Status.Ok, 30.seconds) + } + + private def sendExpectReply(sender: TestAgent, plaintextMessage: PlaintextMessage): ZIO[Client & Resolver & Operations, Throwable, PlaintextMessage] = + for { + encrypted <- authEncrypt(plaintextMessage) + .provideSomeLayer(ZLayer.succeed(sender)) + .mapError(didFailAsThrowable) + response <- postDidComm(encrypted) + message <- ZIO + .fromOption(response) + .orElseFail(new RuntimeException(s"Expected DIDComm reply for ${plaintextMessage.`type`}")) + decrypted <- decrypt(message) + .provideSomeLayer(ZLayer.succeed(sender)) + .mapError(didFailAsThrowable) + plaintext <- decrypted match + case plaintext: PlaintextMessage => ZIO.succeed(plaintext) + case other => + ZIO.fail(new RuntimeException(s"Expected plaintext reply, got ${other.getClass.getSimpleName}")) + } yield plaintext + + private def sendWithoutReply(sender: TestAgent, plaintextMessage: PlaintextMessage): ZIO[Client & Resolver & Operations, Throwable, Unit] = + for { + encrypted <- authEncrypt(plaintextMessage) + .provideSomeLayer(ZLayer.succeed(sender)) + .mapError(didFailAsThrowable) + _ <- postDidComm(encrypted).unit + } yield () + + private def postDidComm(message: EncryptedMessage): ZIO[Client, Throwable, Option[EncryptedMessage]] = + for { + url <- ZIO.fromEither(URL.decode(mediatorEndpoint)).mapError(new RuntimeException(_)) + request = Request + .post(url = url, body = Body.fromString(message.toJson)) + .addHeader(MediaTypes.ENCRYPTED.asContentType) + response <- Client.batched(request) + body <- response.body.asString + maybeMessage <- + if (body.isBlank) ZIO.none + else + ZIO + .fromEither(body.fromJson[Message]) + .mapError(new RuntimeException(_)) + .flatMap { + case encrypted: EncryptedMessage => ZIO.some(encrypted) + case other => + ZIO.fail(new RuntimeException(s"Expected encrypted DIDComm response, got ${other.getClass.getSimpleName}")) + } + } yield maybeMessage + + private def postDidCommMessage(message: EncryptedMessage): ZIO[Client, Throwable, Message] = + for { + url <- ZIO.fromEither(URL.decode(mediatorEndpoint)).mapError(new RuntimeException(_)) + request = Request + .post(url = url, body = Body.fromString(message.toJson)) + .addHeader(MediaTypes.ENCRYPTED.asContentType) + response <- Client.batched(request) + body <- response.body.asString + parsedMessage <- + if (body.isBlank) ZIO.fail(new RuntimeException("Expected DIDComm response body")) + else ZIO.fromEither(body.fromJson[Message]).mapError(new RuntimeException(_)) + } yield parsedMessage + + private def attachmentAsEncryptedMessage(attachment: Option[Attachment]): IO[RuntimeException, EncryptedMessage] = + for { + attachedMessage <- ZIO + .fromOption(attachment) + .orElseFail(new RuntimeException("Missing DIDComm attachment")) + .flatMap(att => ZIO.fromEither(att.getAsMessage).mapError(new RuntimeException(_))) + encrypted <- attachedMessage match + case encrypted: EncryptedMessage => ZIO.succeed(encrypted) + case other => + ZIO.fail(new RuntimeException(s"Expected encrypted attachment, got ${other.getClass.getSimpleName}")) + } yield encrypted + + private def plaintextMediationRequestMessage(didFrom: String, mediatorDid: String): PlaintextMessage = + s"""{ + | "id" : "17f9f122-f762-4ba8-9011-39b9e7efb177", + | "type" : "https://didcomm.org/coordinate-mediation/2.0/mediate-request", + | "to" : [ + | "$mediatorDid" + | ], + | "from" : "$didFrom", + | "body" : {}, + | "return_route" : "all", + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get + + private def plaintextKeyListUpdateRequestMessage(didFrom: String, mediatorDid: String, recipientDid: String): PlaintextMessage = + s"""{ + | "id" : "cf64e501-d524-4fd9-8314-4dc4bc652983", + | "type" : "https://didcomm.org/coordinate-mediation/2.0/keylist-update", + | "to" : [ + | "$mediatorDid" + | ], + | "from" : "$didFrom", + | "body" : { + | "updates" : [ + | { + | "recipient_did" : "$recipientDid", + | "action" : "add" + | } + | ] + | }, + | "return_route" : "all", + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get + + private def plaintextDeliveryRequestMessage(didFrom: String, mediatorDid: String, recipientDid: String): PlaintextMessage = + s"""{ + | "id" : "5d44cc11-d5da-4e19-ba1a-a5279dfea367", + | "type" : "https://didcomm.org/messagepickup/3.0/delivery-request", + | "to" : [ + | "$mediatorDid" + | ], + | "from" : "$didFrom", + | "body" : { + | "limit" : 5, + | "recipient_did" : "$recipientDid" + | }, + | "return_route" : "all", + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get + + private def plainTextBasicMessage(didFrom: String, didTo: String): PlaintextMessage = + s"""{ + | "id" : "e463a417-7661-4764-b60a-21a3e62ad9cf", + | "type" : "https://didcomm.org/basicmessage/2.0/message", + | "to" : [ + | "$didTo" + | ], + | "from" : "$didFrom", + | "body" : { + | "content" : "Hello Alice!" + | }, + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get + + private def plaintextForwardMessage( + didFrom: String, + forwardTo: String, + mediatorDid: String, + attachedMessage: String + ): PlaintextMessage = + s"""{ + | "id" : "f2c8b22f-06ee-4913-b82d-0bc772ade407", + | "type" : "https://didcomm.org/routing/2.0/forward", + | "to" : [ + | "$mediatorDid" + | ], + | "from" : "$didFrom", + | "body" : { + | "next" : "$forwardTo" + | }, + | "attachments" : [ + | { + | "data" : { + | "json" : $attachedMessage + | } + | } + | ], + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get + + private def sha256Hex(bytes: Array[Byte]): String = + hex(MessageDigest.getInstance("SHA-256").digest(bytes)) + + private def base64UrlNoPad(bytes: Array[Byte]): String = + Base64.getUrlEncoder.withoutPadding().encodeToString(bytes) + + private def hex(bytes: Array[Byte]): String = + bytes.map("%02x".format(_)).mkString + + private def didFailAsThrowable(error: DidFail): Throwable = + new RuntimeException(error.toString) +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 212e89fd..85e8b9e1 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -23,8 +23,8 @@ addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.21.1") // GRPC //resolvers += "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots" -//addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.19") -//libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.12" +addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.8") +libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.17" ////https://mvnrepository.com/artifact/com.thesamet.scalapb.grpcweb/scalapb-grpcweb //libraryDependencies += "com.thesamet.scalapb.grpcweb" %% "scalapb-grpcweb-code-gen" % "0.6.4" From 64ae3f41ea5fdf219b760041f3b5d83ee2f4a673 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Fri, 26 Jun 2026 17:33:31 +0800 Subject: [PATCH 3/5] ci(mediator): isolate PRISM e2e workflow Signed-off-by: Yurii Shynbuiev --- .github/workflows/prism-integration-tests.yml | 1 + .../mediator/prism/MediatorPrismE2ESpec.scala | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/prism-integration-tests.yml b/.github/workflows/prism-integration-tests.yml index 4d46bf47..3946ffe0 100644 --- a/.github/workflows/prism-integration-tests.yml +++ b/.github/workflows/prism-integration-tests.yml @@ -25,6 +25,7 @@ jobs: permissions: contents: read env: + MEDIATOR_PRISM_E2E_ENABLED: "true" NEOPRISM_VERSION: "0.14.1" NEOPRISM_BASE_URL: "http://127.0.0.1:18081" MEDIATOR_PRISM_MONGO_URI: "mongodb://127.0.0.1:27018/messages" diff --git a/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala b/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala index 9bd47779..5fc16ef0 100644 --- a/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala +++ b/mediator/src/test/scala/org/hyperledger/identus/mediator/prism/MediatorPrismE2ESpec.scala @@ -34,6 +34,7 @@ import java.util.Base64 object MediatorPrismE2ESpec extends ZIOSpecDefault { + private val prismE2eEnabled = sys.env.get("MEDIATOR_PRISM_E2E_ENABLED").contains("true") private val neoprismBaseUrl = sys.env.getOrElse("NEOPRISM_BASE_URL", "http://127.0.0.1:18081") private val neoprismResolverBaseUrl = s"$neoprismBaseUrl/api/dids" private val mongoConnectionString = @@ -81,7 +82,7 @@ object MediatorPrismE2ESpec extends ZIOSpecDefault { partialFilter = Some(BSONDocument("alias.0" -> BSONDocument("$exists" -> true))) ) - override def spec = + private val prismE2eSpec = suite("MediatorPrismE2ESpec")( test("resolves PRISM long-form and short-form DIDs through NeoPRISM") { ZIO.scoped { @@ -247,6 +248,15 @@ object MediatorPrismE2ESpec extends ZIOSpecDefault { @@ TestAspect.withLiveClock @@ TestAspect.timeout(2.minutes) + override def spec = + if prismE2eEnabled then prismE2eSpec + else + suite("MediatorPrismE2ESpec")( + test("skips PRISM E2E suite unless explicitly enabled") { + assertTrue(true) + } @@ TestAspect.ignore + ) + private def resolverLayer(didPrismResolverBaseUrl: String): ZLayer[Client & Scope, Nothing, Resolver] = ( DidPeerResolver.layerDidPeerResolver ++ From 9557c751c158f92d4b2c4f8a1288a5a2d6260082 Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Fri, 26 Jun 2026 17:53:59 +0800 Subject: [PATCH 4/5] test(mediator): cover resolver fallback replies Signed-off-by: Yurii Shynbuiev --- .../mediator/AgentExecutorMediatorSpec.scala | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala diff --git a/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala b/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala new file mode 100644 index 00000000..54e8b912 --- /dev/null +++ b/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala @@ -0,0 +1,143 @@ +package org.hyperledger.identus.mediator + +import fmgp.crypto.error.{DidFail, ValidationFailed} +import fmgp.did.* +import fmgp.did.comm.Operations +import fmgp.did.comm.protocol.ProtocolExecuter +import fmgp.did.comm.protocol.reportproblem2.{ProblemCode, ProblemReport, toProblemReport} +import fmgp.did.comm.{EncryptedMessage, PlaintextMessage, SignedMessage} +import fmgp.did.framework.* +import fmgp.did.method.peer.DidPeerResolver +import fmgp.did.comm.layerOperations +import org.hyperledger.identus.mediator.db.AgentStub +import org.hyperledger.identus.mediator.protocols.Problems +import zio.* +import zio.json.* +import zio.stream.{ZSink, ZStream} +import zio.test.* + +object AgentExecutorMediatorSpec extends ZIOSpecDefault { + private val mediatorAgent = MediatorAgent(AgentStub.mediatorConfig.did, AgentStub.mediatorConfig.keyStore) + + override def spec = + suite("AgentExecutorMediatorSpec")( + test("resolver fallback wraps plaintext replies into a signed problem-report") { + ZIO.scoped { + for { + transport <- captureTransport + agentExecutor <- testExecutor + plaintext = plaintextBasicMessage + _ <- invokeHandleResolverFailure(agentExecutor, Right(plaintext), transport, ValidationFailed) + .provideSomeLayer(DidPeerResolver.layerDidPeerResolver) + .provideSomeLayer(layerOperations(Operations)) + .provideSomeLayer(ZLayer.succeed(mediatorAgent)) + sent <- transport.queue.take + signed <- ZIO.fromOption(sent match + case msg: SignedMessage => Some(msg) + case _ => None + ) + .orElseFail(new RuntimeException(s"Expected SignedMessage, got ${sent.getClass.getSimpleName}")) + payload <- ZIO.fromEither(signed.payloadAsPlaintextMessage).mapError(err => new RuntimeException(err.toString)) + report <- ZIO.fromOption(payload.toProblemReport.toOption) + .orElseFail(new RuntimeException(s"Expected problem-report, got ${payload.`type`}")) + } yield assertTrue( + report.piuri == ProblemReport.piuri, + report.code == ProblemCode.ErroFail("me", "res", "resolver"), + report.comment.exists(_.contains("Unable to resolve DID while handling message")) + ) + } + }, + test("resolver fallback re-signs an existing problem-report") { + ZIO.scoped { + for { + transport <- captureTransport + agentExecutor <- testExecutor + problemReport = Problems.decryptFail(AgentStub.mediatorConfig.did.asFROM, "decrypt failed") + _ <- invokeHandleResolverFailure(agentExecutor, Left(problemReport), transport, ValidationFailed) + .provideSomeLayer(DidPeerResolver.layerDidPeerResolver) + .provideSomeLayer(layerOperations(Operations)) + .provideSomeLayer(ZLayer.succeed(mediatorAgent)) + sent <- transport.queue.take + signed <- ZIO.fromOption(sent match + case msg: SignedMessage => Some(msg) + case _ => None + ) + .orElseFail(new RuntimeException(s"Expected SignedMessage, got ${sent.getClass.getSimpleName}")) + payload <- ZIO.fromEither(signed.payloadAsPlaintextMessage).mapError(err => new RuntimeException(err.toString)) + report <- ZIO.fromOption(payload.toProblemReport.toOption) + .orElseFail(new RuntimeException(s"Expected problem-report, got ${payload.`type`}")) + } yield assertTrue( + report.comment.contains("decrypt failed"), + report.code == ProblemCode.ErroFail("msg") + ) + } + } + ) + + private case class CaptureTransport( + queue: Queue[SignedMessage | EncryptedMessage], + transport: TransportDIDComm[Any] + ) + + private def captureTransport: UIO[CaptureTransport] = + for { + outboundQueue <- Queue.unbounded[SignedMessage | EncryptedMessage] + } yield CaptureTransport( + queue = outboundQueue, + transport = new TransportDIDComm[Any] { + def transmissionFlow = Transport.TransmissionFlow.BothWays + def transmissionType = Transport.TransmissionType.SingleTransmission + def id: TransportID = "capture-transport" + def inbound = ZStream.empty + def outbound = ZSink.fromQueue(outboundQueue) + } + ) + + private def testExecutor: ZIO[Scope, Nothing, AgentExecutorMediator] = + for { + transportManager <- Ref.make( + MediatorTransportManager( + transportFactory = new TransportFactory { + override def openTransport(uri: String): UIO[TransportDIDComm[Any]] = + ZIO.dieMessage(s"Unexpected openTransport($uri) in AgentExecutorMediatorSpec") + } + ) + ) + scope <- ZIO.service[Scope] + } yield AgentExecutorMediator( + agent = mediatorAgent, + transportManager = transportManager, + protocolHandler = null.asInstanceOf[ProtocolExecuter[OperatorImp.Services, MediatorError | StorageError]], + userAccountRepo = null.asInstanceOf[org.hyperledger.identus.mediator.db.UserAccountRepo], + messageItemRepo = null.asInstanceOf[org.hyperledger.identus.mediator.db.MessageItemRepo], + scope = scope + ) + + private def invokeHandleResolverFailure( + agentExecutor: AgentExecutorMediator, + input: Either[ProblemReport, PlaintextMessage], + transport: CaptureTransport, + didFail: DidFail + ): ZIO[Agent & Operations & Resolver, Nothing, Unit] = { + val method = classOf[AgentExecutorMediator].getDeclaredMethods.find(_.getName.contains("handleResolverFailure")).get + method.setAccessible(true) + method + .invoke(agentExecutor, input.asInstanceOf[AnyRef], transport.transport.asInstanceOf[AnyRef], didFail.asInstanceOf[AnyRef]) + .asInstanceOf[ZIO[Agent & Operations & Resolver, Nothing, Unit]] + } + + private def plaintextBasicMessage: PlaintextMessage = + s"""{ + | "id" : "resolver-fallback-test", + | "type" : "https://didcomm.org/basicmessage/2.0/message", + | "to" : [ + | "${AgentStub.mediatorConfig.did.string}" + | ], + | "from" : "${AgentStub.bobAgent.id.string}", + | "body" : { + | "content" : "hello" + | }, + | "return_route" : "all", + | "typ" : "application/didcomm-plain+json" + |}""".stripMargin.fromJson[PlaintextMessage].toOption.get +} From 5eede41a447379adf7d9545f52586bbc92ea62ad Mon Sep 17 00:00:00 2001 From: Yurii Shynbuiev Date: Fri, 26 Jun 2026 18:02:43 +0800 Subject: [PATCH 5/5] test(mediator): stabilize resolver fallback coverage Signed-off-by: Yurii Shynbuiev --- .../identus/mediator/AgentExecutorMediatorSpec.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala b/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala index 54e8b912..c68bffb5 100644 --- a/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala +++ b/mediator/src/test/scala/org/hyperledger/identus/mediator/AgentExecutorMediatorSpec.scala @@ -119,7 +119,9 @@ object AgentExecutorMediatorSpec extends ZIOSpecDefault { transport: CaptureTransport, didFail: DidFail ): ZIO[Agent & Operations & Resolver, Nothing, Unit] = { - val method = classOf[AgentExecutorMediator].getDeclaredMethods.find(_.getName.contains("handleResolverFailure")).get + val method = classOf[AgentExecutorMediator].getDeclaredMethods.find { method => + method.getName.endsWith("$$handleResolverFailure") && method.getParameterCount == 3 + }.get method.setAccessible(true) method .invoke(agentExecutor, input.asInstanceOf[AnyRef], transport.transport.asInstanceOf[AnyRef], didFail.asInstanceOf[AnyRef])