Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion obp-api/src/main/scala/code/api/constant/constant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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
final lazy val `X-Request-ID` = "X-Request-ID" // Berlin Group

/**
* The If-Modified-Since request HTTP header makes the request conditional:
* the server sends back the requested resource, with a 200 status,
Expand Down
14 changes: 14 additions & 0 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
216 changes: 216 additions & 0 deletions obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala
Original file line number Diff line number Diff line change
@@ -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")
}
}