- Creating a Normalized String
- References
-
How to configure eIDAS to work with our Postman collection? (standalone article)
All of our APIs require a Signature header created using the request content and signed by eIDAS private key. We follow the HTTP Signature spec but we have some restrictions as listed below:
Signature header content
Signature header must contain four parts separated by commas. These are:
1) keyId - TPP client id
2) algorithm - It must ALWAYS contain "rsa-sha256", as it's the only accepted algorithm.
3) headers - All signature headers that will be used to generate signature code must be specified here. For all GET requests in Nordea Open Banking this string is recommended: (request-target) x-nordea-originating-host x-nordea-originating-date as these are required headers to generate signature.
4) signature - Signature normalized string hashed with rsa-sha256 algorithm and then with Base64. So from code perspective it should look like this:
Base64(RSA-SHA256(normalized_string))
Important things to note!
- The only allowed algorithm is RSA-SHA256
- The key size for the used RSA key pair has to be at least 2048 bit
- The keyId is the clientId of your application originating from the Nordea Developer Portal
-
We require the following headers to be used in the signature
- GET and DELETE request: (request-target) X-Nordea-Originating-Host X-Nordea-Originating-Date
- POST, PUT and PATCH request: (request-target) X-Nordea-Originating-Host X-Nordea-Originating-Date Content-type Digest
- The request-target is a combination of the HTTP action verb and the request URI path
-
Please use the URL encoding only in the token endpoint. Apply URL encoding to the complete parameter value. There the content type should be application/x-www-form-urlencoded and the parameters (and only the parameter values, optionally also the keys but we do not require it). More info here: https://en.wikipedia.org/wiki/Percent-encoding#The_application/x-www-form-urlencoded_type
- You should NOT URL encode the redirect_uri and code values
- The digest calculation needs to be done on a normalized (alphabetical) ordering of the Payload especially for a Form URL encoded body.
The Final Signature should be created similarly like this:
signature="Base64(RSA-SHA256(signing string))"
Signature headers for POST and PUT requests
Given example request:
POST: https://open.nordea.com/personal/v4/payments/domestic
X-Nordea-Originating-Host: open.nordea.com
X-Nordea-Originating-Date: Thu, 05 Jun 2019 21:31:40 GMT
Content-Type: application/json
Digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
Signature: keyId="<clientId>",algorithm="rsa-sha256",
headers="<signature headers>", signature="<signature code>"
Required HTTP request headers are: X-Nordea-Originating-Host, X-Nordea-Originating-Date, Signature, Content-Type, Digest
X-Nordea-Originating-Host, X-Nordea-Originating-Date and Signature - the same as in the GET requests.
Content-Type - Type of content sent by a client. In Nordea Open Banking api it's either "application/json" or "application/x-www-form-urlencoded" (for /token endpoints).
Digest - The Digest Header header as defined in [RFC3230] contains a Hash of the message body. The only hash algorithms that may be used to calculate the Digest within the context of this specification are SHA-256 and SHA-512 as defined in [RFC5843]. For POST with ‘application/x-www-form-urlencoded’ content type Digest should be calculated for normalized string created from request parameters like this: 'param1=value1¶m2=value2’. It is important that parameters should be placed in alphabetic order
Here's a code example for creating an eiDAS signature:
import lombok.SneakyThrows;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpRequest;
import org.tomitribe.auth.signatures.Signature;
import org.tomitribe.auth.signatures.Signer;
import java.net.URI;
import java.security.Key;
import java.security.KeyStore;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import static java.util.Optional.ofNullable;
import static org.apache.logging.log4j.util.Strings.isNotEmpty;
import static org.springframework.http.HttpMethod.GET;
/**
* This class is for example purpose only, and is not intended for production use.
* You must modify it to correspond to your encryption keys and setup.
* Example of Signature headers:
* X-Nordea-Originating-Date: Wed, 06 Nov 2019 08:22:42 GMT
* X-Nordea-Originating-Host: open.nordea.com
* Signature: keyId="clientId",algorithm="rsa-sha256",headers="(request-target) x-nordea-originating-host x-nordea-originating-date content-type digest",signature="3wp5Dv/0cEmJ1I103d+dHJdZ+pEi7vtnV26NTMQgD5PUzioRafM6lvh8SwPj/d06Cp0Cza+O6xDJ6irK3GQAC9TMdfPDGBUN4h9pgb8KH2q21hhxe+pIZHmNSRkZ55ZGItiuISKF5tX/KLIzARoYAAPgYJC/WoB68dcuP4qFp74eJQAI49Q79xdeky5qIjXAdiBPDuBR1K7TAA2dbvDJNEWjHKZ4BNkjTKOHOT/LJKD0cE3b3pkagalkde3v9pOtWWYwnPkskJPSeCsR/FpWGieHoAgrCcDJMtQrK3o/sWD5km425jl6X/2Ohs7vdyBCugtqQv8TaH1uskIuSP6fLQ=="
* Digest: sha-256=7ehEApNpa/FuZeL9uEVCI/6Mwp41K0bYG71yTS06Kcs=
*/
public class QSealCSignatureExample {
// Insert keystore passphrase and location according to your environment
private static final char[] STORE_PASS = "YOUR KEYSTORE PASSPHRASE".toCharArray();
private static final String keyStoreLocation = "YOUR KEYSTORE.p12";
// Insert values accordingly to your clientid, host and date
private static final String keyId = "clientId";
private static final String originatingHost = "open.nordea.com";
private static final String originatingDate = "Wed, 06 Nov 2019 08:22:42 GMT";
private static final String[] GET_HEADERS = new String[]{"(request-target)", "x-nordea-originating-host", "x-nordea-originating-date"};
private static final String[] INSERT_HEADERS = new String[]{"(request-target)", "x-nordea-originating-host", "x-nordea-originating-date", "content-type", "digest"};
private static Key key;
@SneakyThrows
public QSealCSignatureExample(@Value(keyStoreLocation) Resource keys) {
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(keys.getInputStream(), STORE_PASS);
this.key = keyStore.getKey("privatekey", STORE_PASS);
}
@SneakyThrows
public String createSignatureHeader(HttpRequest request, byte[] body) {
if (request.getMethod() == GET) {
return createGetSignatureHeader(request);
}
return createInsertSignature(request, body);
}
@SneakyThrows
private String createGetSignatureHeader(HttpRequest request) {
String path = getPath(request.getURI());
Map<String, String> headers = createMandatoryHeaders();
Signature signature = new Signature(keyId, "rsa-sha256", null, GET_HEADERS);
return new Signer(key, signature).sign(request.getMethod().name(), path, headers).toString();
}
@SneakyThrows
private String createInsertSignature(HttpRequest request, byte[] body) {
String path = getPath(request.getURI());
Map<String, String> headers = createMandatoryHeaders();
headers.put("Digest", calculateDigest(body));
headers.put("Content-type", ofNullable(request.getHeaders().getContentType()).map(Objects::toString).orElse(null));
Signature signature = new Signature(keyId, "rsa-sha256", null, INSERT_HEADERS);
return new Signer(key, signature).sign(request.getMethod().name(), path, headers).toString();
}
private Map<String, String> createMandatoryHeaders() {
Map<String, String> headers = new HashMap<>();
headers.put("x-nordea-originating-host", originatingHost);
headers.put("x-nordea-originating-date", originatingDate);
return headers;
}
public String calculateDigest(byte[] body) {
return "SHA-256=" + new String(Base64.getEncoder().encode(DigestUtils.sha256(body)));
}
private String getPath(URI requestURI) {
return requestURI.getRawPath() + getQueryIfNotEmpty(requestURI.getRawQuery());
}
private String getQueryIfNotEmpty(String query) {
return isNotEmpty(query)
? "?" + query
: "";
}
}
Creating a Normalized String
To create signature normalized string all of required headers, specified in Signature (http request header) "headers" part, must be taken into one string along with their values (key: value). Each key: value pair must be separated by new line \n but keep in mind that it must not be used after last pair.
(request-target): ${method} ${path}\nFor our request example normalized string would look like this:
x-nordea-originating-host: ${host}\n
x-nordea-originating-date: ${date}\n
content-type: ${content-type}\n
digest: ${digest}
(request-target): post /personal/v4/payments/domestic\n
x-nordea-originating-host: open.nordea.com\n
x-nordea-originating-date: Thu, 05 Jun 2019 21:31:40 GMT\n
content-type: application/json
digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
Such signature normalized string must be hashed with rsa-sha256 algorithm (using private key) and then Base64 and put into "signature" part of Signature header. <signature code>
We have also noticed that some of our TPPs are having a difficulties in implementing the signature spec correctly so here's a simple JAVA code to do the Normalized Signing String creation.
import java.util.LinkedHashMap;
import java.util.Map;
public class SignatureNormalizedStringBuilder {
/**
* Produces signature normalized string:
* {@code
* "(request-target): post /personal/v4/authorize-decoupled\n" +
* "x-nordea-originating-host: open.nordea.com\n" +
* "x-nordea-originating-date: Fri, 20 Sep 2019 09:41:25 GMT\n" +
* "content-type: application/json\n" +
* "digest: SHA-256=jcC/ttW7JucGTN9hWfqMsFeON6D+vZtQGWJA+W0PL/g="
* }
* MAKE SURE THAT THE LAST LINE DOESN'T HAVE '\n' CHAR
*/
public static void main(String[] args) {
Map<String, String> claims = new LinkedHashMap<>();
// key should be lower case
claims.put("(request-target)", "post /personal/v4/authorize-decoupled");
claims.put("x-nordea-originating-host", "open.nordea.com");
claims.put("x-nordea-originating-date", "Fri, 20 Sep 2019 09:41:25 GMT");
claims.put("content-type", "application/json");
claims.put("digest", "SHA-256=jcC/ttW7JucGTN9hWfqMsFeON6D+vZtQGWJA+W0PL/g=");
SignatureNormalizedStringBuilder builder = new SignatureNormalizedStringBuilder();
claims.forEach(builder::append);
System.out.println("Normalized String: " + builder.normalize());
}
private final StringBuilder builder = new StringBuilder();
SignatureNormalizedStringBuilder append(String key, String value) {
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(key).append(": ").append(value);
return this;
}
String normalize() {
String normalizedSignature = builder.toString();
return normalizedSignature;
}
}
Troubleshooting
-
Normalized string must contain all required headers (key) and their values.
-
For normalized string required headers (keys) names must be given in lowercase. This is not required in HTTP request itself. But all of values must be exactly the same as in the request headers. So if they contain capital letters then the same must be used in normalized string.
-
Each key: value pair must be separated by \n (new line). But it can't be used after last pair.
-
Key: value pairs in normalized string must be given in the same order as they were specified in Signature (HTTP request header) "headers" part. So if (request-target) x-nordea-originating-host x-nordea-originating-date has been given there then normalized string must be generated in the same order. This also means that if you will give other order in the header than (request-target) x-nordea-originating-host x-nordea-originating-date and will create normalized string using the same order it will work
-
Note that you will get signature error if you send Content-Type: application/json; charset=utf-8 and calculate signature for content-type: application/json; charset=UTF-8.
-
If you get this error "<h1>Bad Message 400</h1><pre>reason: Illegal character VCHAR='('</pre>" you should try removing (request-target) from the payload
- If you get an 403 error "Forbidden / Certificate has expired or it has been revoked.", you need to contact your QTSP to learn more about your eIDAS certificate status