diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/controller/CredentialIssuerController.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/controller/CredentialIssuerController.scala index ca1811859f..361061b459 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/controller/CredentialIssuerController.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/controller/CredentialIssuerController.scala @@ -5,11 +5,13 @@ import org.hyperledger.identus.api.http.{ErrorResponse, RequestContext} import org.hyperledger.identus.api.http.ErrorResponse.{badRequest, internalServerError} import org.hyperledger.identus.api.util.PaginationUtils import org.hyperledger.identus.castor.core.model.did.PrismDID +import org.hyperledger.identus.oid4vci.domain.Openid4VCIProofJwtOps import org.hyperledger.identus.oid4vci.http.* import org.hyperledger.identus.oid4vci.http.CredentialErrorCode.* import org.hyperledger.identus.oid4vci.service.OIDCCredentialIssuerService import org.hyperledger.identus.pollux.core.model.oid4vci.CredentialIssuer as PolluxCredentialIssuer import org.hyperledger.identus.pollux.core.service.OID4VCIIssuerMetadataService +import org.hyperledger.identus.pollux.vc.jwt.JWT import org.hyperledger.identus.shared.models.WalletAccessContext import zio.{IO, URLayer, ZIO, ZLayer} @@ -117,7 +119,8 @@ case class CredentialIssuerControllerImpl( credentialIssuerService: OIDCCredentialIssuerService, issuerMetadataService: OID4VCIIssuerMetadataService, agentBaseUrl: URL -) extends CredentialIssuerController { +) extends CredentialIssuerController + with Openid4VCIProofJwtOps { import CredentialIssuerController.Errors.* import OIDCCredentialIssuerService.Errors.* @@ -159,14 +162,15 @@ case class CredentialIssuerControllerImpl( case Some(JwtProof(proofType, jwt)) => for { _ <- ZIO - .ifZIO(credentialIssuerService.verifyJwtProof(jwt))( + .ifZIO(credentialIssuerService.verifyJwtProof(JWT(jwt)))( ZIO.unit, ZIO.fail(OIDCCredentialIssuerService.Errors.InvalidProof("Invalid proof")) ) .mapError { case InvalidProof(message) => badRequestInvalidProof(jwt, message) } - nonce = "todo - extract jwt nonce" // TODO: validate proof and extract nonce + nonce <- getNonceFromJwt(JWT(jwt)) + .mapError(throwable => badRequestInvalidProof(jwt, throwable.getMessage)) session <- credentialIssuerService .getIssuanceSessionByNonce(nonce) .mapError(_ => badRequestInvalidProof(jwt, "nonce is not associated to the issuance session")) diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/domain/Openid4VCIProofJwtOps.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/domain/Openid4VCIProofJwtOps.scala new file mode 100644 index 0000000000..b4a65046b0 --- /dev/null +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/domain/Openid4VCIProofJwtOps.scala @@ -0,0 +1,66 @@ +package org.hyperledger.identus.oid4vci.domain + +import com.nimbusds.jose.{JOSEObjectType, JWSAlgorithm, JWSHeader, JWSObject, JWSSigner, Payload} +import org.hyperledger.identus.castor.core.model.did.LongFormPrismDID +import org.hyperledger.identus.pollux.vc.jwt.{DidResolver, JWT} +import org.hyperledger.identus.pollux.vc.jwt.JwtSignerImplicits.* +import org.hyperledger.identus.shared.crypto.Secp256k1PrivateKey +import zio.Task +import zio.ZIO + +import java.util.UUID +import scala.jdk.CollectionConverters.* +import scala.util.Try + +trait Openid4VCIProofJwtOps { + + val jwtTypeName = "openid4vci-proof+jwt" + + val supportedAlgorithms: Set[String] = Set("ES256K") + + private def buildHeaderFromLongFormDid(longFormDID: LongFormPrismDID) = { + new JWSHeader.Builder(JWSAlgorithm.ES256K) + .keyID(longFormDID.toString) + .`type`(new JOSEObjectType(jwtTypeName)) + .build() + } + + private def buildPayload(nonce: String, aud: UUID, iat: Int) = { + new Payload(Map("nonce" -> nonce, "aud" -> aud.toString, "iat" -> iat).asJava) + } + + private def makeJwtProof(header: JWSHeader, payload: Payload, signer: JWSSigner): String = { + val jwt = new JWSObject(header, payload) + jwt.sign(signer) + jwt.serialize() + } + + def makeJwtProof( + longFormPrismDID: LongFormPrismDID, + nonce: String, + aud: UUID, + iat: Int, + privateKey: Secp256k1PrivateKey + ): JWT = { + val header = buildHeaderFromLongFormDid(longFormPrismDID) + val payload = buildPayload(nonce, aud, iat) + JWT(makeJwtProof(header, payload, privateKey.asJwtSigner)) + } + + def getKeyIdFromJwt(jwt: JWT): Task[String] = { + for { + jwsObject <- ZIO.fromTry(Try(JWSObject.parse(jwt.value))) + keyID = jwsObject.getHeader.getKeyID + _ <- ZIO.fail(new Exception("Key ID not found in JWT header")) when (keyID == null || keyID.isEmpty) + } yield keyID + } + + def getNonceFromJwt(jwt: JWT): Task[String] = { + for { + jwsObject <- ZIO.fromTry(Try(JWSObject.parse(jwt.value))) + payload = jwsObject.getPayload.toJSONObject + nonce = payload.get("nonce").asInstanceOf[String] + _ <- ZIO.fail(new Exception("Nonce not found in JWT payload")) when (nonce == null || nonce.isEmpty) + } yield nonce + } +} diff --git a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/service/OIDCCredentialIssuerService.scala b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/service/OIDCCredentialIssuerService.scala index ce87aed52e..3de7bf2565 100644 --- a/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/service/OIDCCredentialIssuerService.scala +++ b/cloud-agent/service/server/src/main/scala/org/hyperledger/identus/oid4vci/service/OIDCCredentialIssuerService.scala @@ -7,7 +7,8 @@ import org.hyperledger.identus.oid4vci.domain.IssuanceSession import org.hyperledger.identus.oid4vci.http.* import org.hyperledger.identus.oid4vci.storage.IssuanceSessionStorage import org.hyperledger.identus.pollux.core.service.CredentialService -import org.hyperledger.identus.pollux.vc.jwt.{DID, Issuer, JWT, JwtCredential, W3cCredentialPayload} +import org.hyperledger.identus.pollux.vc.jwt.{DID, Issuer, JWT, JWTVerification, JwtCredential, W3cCredentialPayload} +import org.hyperledger.identus.pollux.vc.jwt.DidResolver import org.hyperledger.identus.shared.models.{WalletAccessContext, WalletId} import zio.* @@ -24,7 +25,7 @@ trait OIDCCredentialIssuerService { import OIDCCredentialIssuerService.Error import OIDCCredentialIssuerService.Errors.* - def verifyJwtProof(jwt: String): IO[InvalidProof, Boolean] + def verifyJwtProof(jwt: JWT): IO[InvalidProof, Boolean] def validateCredentialDefinition( credentialDefinition: CredentialDefinition @@ -71,13 +72,25 @@ object OIDCCredentialIssuerService { case class OIDCCredentialIssuerServiceImpl( didNonSecretStorage: DIDNonSecretStorage, credentialService: CredentialService, - issuanceSessionStorage: IssuanceSessionStorage + issuanceSessionStorage: IssuanceSessionStorage, + didResolver: DidResolver ) extends OIDCCredentialIssuerService { import OIDCCredentialIssuerService.Error import OIDCCredentialIssuerService.Errors.* - override def verifyJwtProof(jwt: String): IO[InvalidProof, Boolean] = { - ZIO.succeed(true) // TODO: implement + + override def verifyJwtProof(jwt: JWT): IO[InvalidProof, Boolean] = { + for { + verifiedJwtSignature <- JWTVerification + .validateEncodedJwtWithKeyId( + jwt, + proofPurpose = Some(VerificationRelationship.AssertionMethod), + didResolver = didResolver + ) + .mapError(InvalidProof.apply) + _ <- verifiedJwtSignature.toZIO.mapError(InvalidProof.apply) + _ <- ZIO.succeed(println(s"JWT proof is verified: ${jwt.value}")) + } yield true } override def validateCredentialDefinition( @@ -213,8 +226,8 @@ case class OIDCCredentialIssuerServiceImpl( object OIDCCredentialIssuerServiceImpl { val layer: URLayer[ - DIDNonSecretStorage & CredentialService & IssuanceSessionStorage, + DIDNonSecretStorage & CredentialService & IssuanceSessionStorage & DidResolver, OIDCCredentialIssuerService ] = - ZLayer.fromFunction(OIDCCredentialIssuerServiceImpl(_, _, _)) + ZLayer.fromFunction(OIDCCredentialIssuerServiceImpl(_, _, _, _)) } diff --git a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala index 0fcc1168b3..62e293a8f5 100644 --- a/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala +++ b/cloud-agent/service/server/src/test/scala/org/hyperledger/identus/oid4vci/domain/OIDCCredentialIssuerServiceSpec.scala @@ -1,9 +1,13 @@ package org.hyperledger.identus.oid4vci.domain +import com.nimbusds.jose.* +import com.nimbusds.jose.crypto.* +import com.nimbusds.jose.jwk.* +import com.nimbusds.jose.jwk.gen.* import org.hyperledger.identus.agent.walletapi.memory.GenericSecretStorageInMemory import org.hyperledger.identus.agent.walletapi.service.{ManagedDIDService, MockManagedDIDService} import org.hyperledger.identus.agent.walletapi.storage.{DIDNonSecretStorage, MockDIDNonSecretStorage} -import org.hyperledger.identus.castor.core.model.did.VerificationRelationship +import org.hyperledger.identus.castor.core.model.did.{PrismDID, VerificationRelationship} import org.hyperledger.identus.castor.core.service.{DIDService, MockDIDService} import org.hyperledger.identus.oid4vci.http.{ClaimDescriptor, CredentialDefinition, Localization} import org.hyperledger.identus.oid4vci.service.{OIDCCredentialIssuerService, OIDCCredentialIssuerServiceImpl} @@ -14,14 +18,23 @@ import org.hyperledger.identus.pollux.core.repository.{ CredentialStatusListRepositoryInMemory } import org.hyperledger.identus.pollux.core.service.* -import org.hyperledger.identus.pollux.vc.jwt.PrismDidResolver +import org.hyperledger.identus.pollux.vc.jwt.{ES256KSigner, PrismDidResolver} +import org.hyperledger.identus.pollux.vc.jwt.JWT import org.hyperledger.identus.shared.models.WalletId import zio.{URLayer, ZIO, ZLayer} import zio.mock.MockSpecDefault import zio.test.* import zio.test.Assertion.* +import zio.Clock +import zio.Random -object OIDCCredentialIssuerServiceSpec extends MockSpecDefault with CredentialServiceSpecHelper { +import java.util.UUID +import scala.jdk.CollectionConverters._ + +object OIDCCredentialIssuerServiceSpec + extends MockSpecDefault + with CredentialServiceSpecHelper + with Openid4VCIProofJwtOps { val layers: URLayer[ DIDService & ManagedDIDService & DIDNonSecretStorage, @@ -44,17 +57,21 @@ object OIDCCredentialIssuerServiceSpec extends MockSpecDefault with CredentialSe ) override def spec = suite("CredentialServiceImpl")( - OIDCCredentialIssuerServiceSpec + OIDCCredentialIssuerServiceSpec, + validateProofSpec ) private val (issuerOp, issuerKp, issuerDidMetadata, issuerDidData) = MockDIDService.createDID(VerificationRelationship.AssertionMethod) -// private val (holderOp, holderKp, holderDidMetadata, holderDidData) = -// MockDIDService.createDID(VerificationRelationship.Authentication) -// -// private val holderDidServiceExpectations = -// MockDIDService.resolveDIDExpectation(holderDidMetadata, holderDidData) + private val (holderOp, holderKp, holderDidMetadata, holderDidData) = + MockDIDService.createDID(VerificationRelationship.AssertionMethod) + + private val holderDidServiceExpectations = + MockDIDService.resolveDIDExpectation(holderDidMetadata, holderDidData) + + private val holderManagedDIDServiceExpectations = + MockManagedDIDService.javaKeyPairWithDIDExpectation(holderKp) private val issuerDidServiceExpectations = MockDIDService.resolveDIDExpectation(issuerDidMetadata, issuerDidData) @@ -65,6 +82,36 @@ object OIDCCredentialIssuerServiceSpec extends MockSpecDefault with CredentialSe private val getIssuerPrismDidWalletIdExpectation = MockDIDNonSecretStorage.getPrismDidWalletIdExpectation(issuerDidData.id, WalletId.default) + private def buildJwtProof(nonce: String, aud: UUID, iat: Int) = { + import org.bouncycastle.util.encoders.Hex + + val longFormDid = PrismDID.buildLongFormFromOperation(holderOp) + + val encodedKey = Hex.toHexString(holderKp.privateKey.getEncoded) + println(s"Private Key: $encodedKey") + println("Long Form DID: " + longFormDid.toString) + + makeJwtProof(longFormDid, nonce, aud, iat, holderKp.privateKey) + } + + private val validateProofSpec = suite("Validate holder's proof of possession using the LongFormPrismDID")( + test("should validate the holder's proof of possession using the LongFormPrismDID") { + for { + credentialIssuer <- ZIO.service[OIDCCredentialIssuerService] + nonce <- Random.nextString(10) + aud <- Random.nextUUID + iat <- Clock.instant.map(_.getEpochSecond.toInt) + jwt = buildJwtProof(nonce, aud, iat) + result <- credentialIssuer.verifyJwtProof(jwt) + } yield assert(result)(equalTo(true)) + }.provideSomeLayer( + holderDidServiceExpectations.toLayer ++ + MockManagedDIDService.empty ++ + // holderManagedDIDServiceExpectations.toLayer ++ + MockDIDNonSecretStorage.empty >+> layers + ) + ) + private val OIDCCredentialIssuerServiceSpec = suite("Simple JWT credential issuance")( test("should issue a JWT credential") { diff --git a/examples/st-oid4vci/README.md b/examples/st-oid4vci/README.md index 867ba64c81..d12cac6eda 100644 --- a/examples/st-oid4vci/README.md +++ b/examples/st-oid4vci/README.md @@ -11,8 +11,8 @@ Example of the script to install the required packages in a virtual environment: ```shell -python -m venv {path-to-the-project-dir}/open-enterprise-agent/examples/st-oid4vci/python-env -source {path-to-the-project-dir}/open-enterprise-agent/examples/st-oid4vci/python-env/bin/activate +python -m venv {path-to-the-project-dir}/identus-cloud-agent/examples/st-oid4vci/python-env +source {path-to-the-project-dir}/identus-cloud-agent/examples/st-oid4vci/python-env/bin/activate pip install requests pyjwt cryptography ``` diff --git a/examples/st-oid4vci/demo.py b/examples/st-oid4vci/demo.py index 26134e67f0..37bf3d9fcd 100755 --- a/examples/st-oid4vci/demo.py +++ b/examples/st-oid4vci/demo.py @@ -5,6 +5,7 @@ import urllib from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization MOCKSERVER_URL = "http://localhost:7777" @@ -18,6 +19,8 @@ ALICE_CLIENT_ID = "alice-wallet" +HOLDER_LONG_FORM_DID = "did:prism:73196107e806b084d44339c847a3ae8dd279562f23895583f62cc91a2ee5b8fe:CnsKeRI8CghtYXN0ZXItMBABSi4KCXNlY3AyNTZrMRIhArrplJNfQYxthryRU87XdODy-YWUh5mqrvIfAdoZFeJBEjkKBWtleS0wEAJKLgoJc2VjcDI1NmsxEiEC8rsFplfYvRLazdWWi3LNR1gaAQXb-adVhZacJT4ntwE" +HOLDER_ASSERTION_PRIVATE_KEY_HEX = "2902637d412190fb08f5d0e0b2efc1eefae8060ae151e7951b69afbecbdd452e" def prepare_mock_server(): # reset mock server @@ -152,6 +155,7 @@ def holder_get_issuer_as_metadata(authorization_server: str): metadata_url = f"{authorization_server}/.well-known/openid-configuration" response = requests.get(metadata_url) metadata = response.json() + print(f"Metadata: {metadata}") return metadata @@ -221,11 +225,15 @@ def holder_get_credential(credential_endpoint: str, token_response): c_nonce = token_response["c_nonce"] # generate proof - private_key = ec.generate_private_key(ec.SECP256K1()) + private_key_bytes = bytes.fromhex(HOLDER_ASSERTION_PRIVATE_KEY_HEX) + private_key = ec.derive_private_key( + int.from_bytes(private_key_bytes, "big"), ec.SECP256K1() + ) + public_key = private_key.public_key() jwt_proof = jwt.encode( headers={ "typ": "openid4vci-proof+jwt", - "kid": "did:prism:0000000000000000000000000000000000000000000000000000000000000000#key-1", # TODO: use actual DID + "kid": HOLDER_LONG_FORM_DID, }, payload={ "iss": ALICE_CLIENT_ID, 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 e100e9fab5..731f56f4c0 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 @@ -1,12 +1,18 @@ package org.hyperledger.identus.pollux.vc.jwt import com.nimbusds.jose.{JOSEObjectType, JWSAlgorithm, JWSHeader} -import com.nimbusds.jose.crypto.{ECDSASigner, Ed25519Signer} +import com.nimbusds.jose.crypto.{ECDSASigner, ECDSAVerifier, Ed25519Signer} import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton import com.nimbusds.jose.jwk.{Curve, ECKey, OctetKeyPair} import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} import io.circe.* -import org.hyperledger.identus.shared.crypto.Ed25519KeyPair +import org.hyperledger.identus.shared.crypto.{ + Ed25519KeyPair, + Ed25519PrivateKey, + Ed25519PublicKey, + Secp256k1PrivateKey, + Secp256k1PublicKey +} import zio.* import java.security.* @@ -22,6 +28,19 @@ object JWT { } } +object JwtSignerImplicits { + import com.nimbusds.jose.JWSSigner + + implicit class JwtSignerProviderSecp256k1(secp256k1PrivateKey: Secp256k1PrivateKey) { + def asJwtSigner: JWSSigner = { + val ecdsaSigner = ECDSASigner(secp256k1PrivateKey.toJavaPrivateKey, Curve.SECP256K1) + val bouncyCastleProvider = BouncyCastleProviderSingleton.getInstance + ecdsaSigner.getJCAContext.setProvider(bouncyCastleProvider) + ecdsaSigner + } + } +} + trait Signer { def encode(claim: Json): JWT 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 e0080ecb0a..9f4f5e1409 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 @@ -11,6 +11,7 @@ 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.PrismDID import org.hyperledger.identus.castor.core.model.did.VerificationRelationship import org.hyperledger.identus.shared.crypto.Ed25519PublicKey import pdi.jwt.* @@ -89,6 +90,17 @@ object JWTVerification { loadDidDocument } + def validateIssuerFromKeyId( + extractedDID: Validation[String, String] + )(didResolver: DidResolver): IO[String, Validation[String, DIDDocument]] = { + val loadDidDocument = + ValidationUtils + .foreach(extractedDID.map(validIssuerDid => resolve(validIssuerDid)(didResolver)))(identity) + .map(b => b.flatten) + + loadDidDocument + } + def validateEncodedJwt[T](jwt: JWT, proofPurpose: Option[VerificationRelationship] = None)( didResolver: DidResolver )(decoder: String => Validation[String, T])(issuerDidExtractor: T => String): IO[String, Validation[String, Unit]] = { @@ -125,6 +137,61 @@ object JWTVerification { }) } + def keyIdDIDExtractor(jwt: JWT): Validation[String, String] = { + for { + header <- Validation + .fromTry( + JwtCirce + .decodeAll(jwt.value, JwtOptions(false, false, false)) + .map(_._1) + ) + .mapError(_.getMessage) + keyId <- Validation.fromOptionWith("Key ID not found in JWT header")(header.keyId) + isValidPrismDID <- Validation.fromEither(PrismDID.fromString(keyId)) // TODO: we don't support other DIDs yet + } yield keyId + } + + def validateEncodedJwtWithKeyId( + jwt: JWT, + proofPurpose: Option[VerificationRelationship] = None, + didResolver: DidResolver, + didExtractor: JWT => Validation[String, String] = keyIdDIDExtractor + ): IO[String, Validation[String, Unit]] = { + val decodedJWT = Validation + .fromTry(JwtCirce.decodeRawAll(jwt.value, JwtOptions(false, false, false))) + .mapError(_.getMessage) + + val extractAlgorithm: Validation[String, JwtAlgorithm] = + for { + decodedJwtTask <- decodedJWT + (header, _, _) = decodedJwtTask + algorithm <- Validation + .fromOptionWith("An algorithm must be specified in the header")(JwtCirce.parseHeader(header).algorithm) + } yield algorithm + + val claim: Validation[String, String] = + for { + decodedJwtTask <- decodedJWT + (_, claim, _) = decodedJwtTask + } yield claim + + val extractedDID = didExtractor(jwt) + + val loadDidDocument = validateIssuerFromKeyId(extractedDID)(didResolver) + + loadDidDocument + .map(validatedDidDocument => { + for { + results <- Validation.validateWith(validatedDidDocument, extractAlgorithm)((didDocument, algorithm) => + (didDocument, algorithm) + ) + (didDocument, algorithm) = results + verificationMethods <- extractVerificationMethods(didDocument, algorithm, proofPurpose) + validatedJwt <- validateEncodedJwt(jwt, verificationMethods) + } yield validatedJwt + }) + } + def toECDSAVerifier(publicKey: PublicKey): JWSVerifier = { val verifier: JWSVerifier = publicKey match { case key: ECPublicKey => ECDSAVerifier(key)