diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index cbb9259ed..abc83957f 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -144,9 +144,15 @@ object RequestHeader { final lazy val `Consent-JWT` = "Consent-JWT" final lazy val `PSD2-CERT` = "PSD2-CERT" final lazy val `If-None-Match` = "If-None-Match" - final lazy val `TPP-Redirect-URL` = "TPP-Redirect-URL" // Berlin Group - final lazy val `TPP-Signature-Certificate` = "TPP-Signature-Certificate" // Berlin Group + final lazy val `X-Request-ID` = "X-Request-ID" // Berlin Group + final lazy val `TPP-Redirect-URL` = "TPP-Redirect-URL" // Berlin Group + final lazy val Date = "Date" // Berlin Group + // Headers to support the signature function of Berlin Group + final lazy val Digest = "Digest" // Berlin Group + final lazy val Signature = "Signature" // Berlin Group + final lazy val `TPP-Signature-Certificate` = "TPP-Signature-Certificate" // Berlin Group + /** * The If-Modified-Since request HTTP header makes the request conditional: * the server sends back the requested resource, with a 200 status, diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 05c4595d0..121cc6bd7 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3239,6 +3239,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) // Verify signed request JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) + } map { result => + val url = result._2.map(_.url).getOrElse("None") + val verb = result._2.map(_.verb).getOrElse("None") + val body = result._2.flatMap(_.httpBody) + val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + // Verify signed request (Berlin Group) + BerlinGroupSigning.verifySignedRequest(body, verb, url, reqHeaders, result) } map { result => val excludeFunctions = getPropsValue("rate_limiting.exclude_endpoints", "root").split(",").toList @@ -3288,6 +3295,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) // Verify signed request if need be JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) + } map { result => + val url = result._2.map(_.url).getOrElse("None") + val verb = result._2.map(_.verb).getOrElse("None") + val body = result._2.flatMap(_.httpBody) + val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + // Verify signed request if need be + BerlinGroupSigning.verifySignedRequest(body, verb, url, reqHeaders, result) } map { result => result._1 match { case Empty if result._2.flatMap(_.consumer).isDefined => // There is no error and Consumer is defined diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala new file mode 100644 index 000000000..3cb253db0 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -0,0 +1,216 @@ +package code.api.util + +import code.api.{CertificateConstants, RequestHeader} +import com.openbankproject.commons.model.User +import net.liftweb.common.{Box, Failure, Full} +import net.liftweb.http.provider.HTTPParam + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} +import java.security._ +import java.security.cert.{CertificateFactory, X509Certificate} +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import scala.util.matching.Regex + +object BerlinGroupSigning { + + // Step 1: Calculate Digest (SHA-256 Hash of the Body) + def calculateDigest(body: String): String = { + def removeFirstAndLastQuotes(input: String): String = { + if (input.startsWith("\"") && input.endsWith("\"") && input.length > 1) { + input.tail.init + } else { + input + } + } + val digest = MessageDigest.getInstance("SHA-256").digest(removeFirstAndLastQuotes(body).getBytes(StandardCharsets.UTF_8)) + Base64.getEncoder.encodeToString(digest) + } + + // Step 2: Create Signing String (Concatenation of required headers) + def createSigningString(headers: Map[String, String]): String = { + // headers=”digest date x-request-id tpp-redirect-uri” + val orderedKeys = List( + RequestHeader.Digest, + RequestHeader.Date, + RequestHeader.`X-Request-ID`, + RequestHeader.`TPP-Redirect-URL`, + ) // Example fields to be signed + orderedKeys.flatMap(headers.get).mkString(" ") + } + + // Step 3: Generate Signature using RSA Private Key + def signString(signingString: String, privateKeyPem: String): String = { + val privateKey = loadPrivateKey(privateKeyPem) + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(privateKey) + signature.update(signingString.getBytes(StandardCharsets.UTF_8)) + Base64.getEncoder.encodeToString(signature.sign()) + } + + // Load RSA Private Key from PEM String + def loadPrivateKey(pem: String): PrivateKey = { + val keyString = pem + .replaceAll("-----BEGIN .*?PRIVATE KEY-----", "") // Remove headers + .replaceAll("-----END .*?PRIVATE KEY-----", "") + .replaceAll("\\s", "") // Remove all whitespace and new lines + + val decodedKey = Base64.getDecoder.decode(keyString) + val keySpec = new PKCS8EncodedKeySpec(decodedKey) + KeyFactory.getInstance("RSA").generatePrivate(keySpec) + } + + // Step 4: Attach Certificate (Load from PEM String) + def loadCertificate(certPem: String): String = { + val certString = certPem + .replaceAll("-----BEGIN CERTIFICATE-----", "") // Remove the BEGIN header + .replaceAll("-----END CERTIFICATE-----", "") // Remove the END footer + .replaceAll("\\s", "") // Remove all whitespace and new lines + + val certBytes = Base64.getDecoder.decode(certString) + Base64.getEncoder.encodeToString(certBytes) + } + + // Step 5: Verify Request on ASPSP Side + def verifySignature(signingString: String, signatureStr: String, certPem: String): Boolean = { + val publicKey = loadPublicKeyFromCert(certPem) + val signature = Signature.getInstance("SHA256withRSA") + signature.initVerify(publicKey) + signature.update(signingString.getBytes(StandardCharsets.UTF_8)) + signature.verify(Base64.getDecoder.decode(signatureStr)) + } + + // Extract Public Key from PEM Certificate String + def loadPublicKeyFromCert(certPem: String): PublicKey = { + val certString = certPem + .replaceAll("-----BEGIN CERTIFICATE-----", "") // Remove the BEGIN header + .replaceAll("-----END CERTIFICATE-----", "") // Remove the END footer + .replaceAll("\\s", "") // Remove all whitespace and new lines + + val certBytes = Base64.getDecoder.decode(certString) + val certFactory = CertificateFactory.getInstance("X.509") + val cert = certFactory.generateCertificate(new java.io.ByteArrayInputStream(certBytes)).asInstanceOf[X509Certificate] + cert.getPublicKey + } + + + /** + * Verifies Signed Request. It assumes that Customers has a sored certificate. + * + * @param body of the signed request + * @param verb GET, POST, DELETE, etc. + * @param url of the the signed request. For example: /berlin-group/v1.3/payments/sepa-credit-transfers + * @param reqHeaders All request headers of the signed request + * @param forwardResult Propagated result of calling function + * @return Propagated result of calling function or signing request error + */ + def verifySignedRequest(body: Box[String], verb: String, url: String, reqHeaders: List[HTTPParam], forwardResult: (Box[User], Option[CallContext])) = { + def checkRequestIsSigned(requestHeaders: List[HTTPParam]): Boolean = { + requestHeaders.exists(_.name == RequestHeader.`TPP-Signature-Certificate`) && + requestHeaders.exists(_.name == RequestHeader.Signature) && + requestHeaders.exists(_.name == RequestHeader.Digest) + } + checkRequestIsSigned(forwardResult._2.map(_.requestHeaders).getOrElse(Nil)) match { + case false => + forwardResult + case true => + val requestHeaders = forwardResult._2.map(_.requestHeaders).getOrElse(Nil) + val certificatePem: String = getPem(requestHeaders) + X509.validate(certificatePem) match { + case Full(true) => // PEM certificate is ok + val digest = calculateDigest(body.getOrElse("")) + val headers = Map( + RequestHeader.Digest -> s"SHA-256=$digest", + RequestHeader.`X-Request-ID` -> getHeaderValue(RequestHeader.`X-Request-ID`, requestHeaders), + RequestHeader.Date -> getHeaderValue(RequestHeader.Date, requestHeaders), + RequestHeader.`TPP-Redirect-URL` -> getHeaderValue(RequestHeader.`TPP-Redirect-URL`, requestHeaders), + ) + val signingString = createSigningString(headers) + val signatureHeaderValue = getHeaderValue(RequestHeader.Signature, requestHeaders) + val signature = parseSignatureHeader(signatureHeaderValue).getOrElse("signature", "NONE") + val isVerified = verifySignature(signingString, signature, certificatePem) + if (isVerified) forwardResult else (Failure(ErrorMessages.X509PublicKeyCannotVerify), forwardResult._2) + case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // PEM certificate is not valid + case _ => (Failure(ErrorMessages.X509GeneralError), forwardResult._2) // PEM certificate cannot be validated + } + } + } + + def getHeaderValue(name: String, requestHeaders: List[HTTPParam]): String = { + requestHeaders.find(_.name == name).map(_.values.mkString).getOrElse("None") + } + def getPem(requestHeaders: List[HTTPParam]): String = { + val certificate = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders) + s"""${CertificateConstants.BEGIN_CERT} + |$certificate + |${CertificateConstants.END_CERT} + |""".stripMargin + } + + def parseSignatureHeader(signatureHeader: String): Map[String, String] = { + val regex = new Regex("""(\w+)\s*=\s*"([^"]*)"""", "key", "value") + regex.findAllMatchIn(signatureHeader).map(m => m.group("key") -> m.group("value")).toMap + } + + // Example Usage + def main(args: Array[String]): Unit = { + val requestBody = """"{ + | "access": { + | "accounts": [ + | { + | "iban": "RS35260005601001611379" + | } + | ], + | "balances": [ + | { + | "iban": "RS35260005601001611379" + | } + | ] + | }, + | "recurringIndicator": true, + | "validUntil": "2025-01-20T11:04:20Z", + | "frequencyPerDay": 10, + | "combinedServiceIndicator": false + |}"""".stripMargin + val digest = calculateDigest(requestBody) + + val xRequestId = "12345678" + val date = "Tue, 13 Feb 2024 10:00:00 GMT" + val redirectUri = "www.redirect-uri.com" + val headers = Map( + RequestHeader.Digest -> s"SHA-256=$digest", + RequestHeader.`X-Request-ID` -> xRequestId, + RequestHeader.Date -> date, + RequestHeader.`TPP-Redirect-URL` -> redirectUri, + ) + + val signingString = createSigningString(headers) + + // Load PEM files as strings + val privateKeyPath = "/home/marko/Downloads/BerlinGroupSigning/private_key.pem" + val certificatePath = "/home/marko/Downloads/BerlinGroupSigning/certificate.pem" + + val privateKeyPem = new String(Files.readAllBytes(Paths.get(privateKeyPath))) + val certificatePem = new String(Files.readAllBytes(Paths.get(certificatePath))) + + val signature = signString(signingString, privateKeyPem) + val certificate = loadCertificate(certificatePem) + + println(s"1) Digest: SHA-256=$digest") + println(s"2) ${RequestHeader.`X-Request-ID`}: $xRequestId") + println(s"3) ${RequestHeader.Date}: $date") + println(s"4) ${RequestHeader.`TPP-Redirect-URL`}: $redirectUri") + val signatureHeaderValue = + s"""keyId="SN=4000000010FC01D520258AB15EAF, CA=CN=D-eSystemTrustIB, O=IP STISC 1003600096694, C-MD", algorithm="rsa-sha256", headers="digest date x-request-id tpp-redirect-uri", signature="$signature"""".stripMargin + println(s"5) Signature: $signatureHeaderValue") + println(s"6) TPP-Signature-Certificate: $certificate") + + val isVerified = verifySignature(signingString, signature, certificatePem) + println(s"Signature Verification: $isVerified") + + + val parsedSignature = parseSignatureHeader(signatureHeaderValue) + println(s"Parsed Signature Header: $parsedSignature") + } +}