> For the complete documentation index, see [llms.txt](https://partner.doc.bunq.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://partner.doc.bunq.com/documentation/bunq-as-a-service/chapter-6-cards/encryption.md).

# Encryption

## Encrypting card requests <a href="#encrypting-card-requests" id="encrypting-card-requests"></a>

All card-related endpoints require the request body to be **encrypted at the application layer**, on top of TLS. This is a PCI-DSS requirement: sensitive card data (PAN, CVC2, PIN) must never travel — or be logged — in the clear, even inside an encrypted TLS tunnel.

A plain (unencrypted) request to a card endpoint is rejected with `HTTP 400 — Encryption required for this endpoint.`

> **Rollout.** Encryption is mandatory across the entire card endpoint tree. If you integrate any card endpoint, implement encryption now so you are not affected as the requirement is switched on for the remaining endpoints.

This page explains the scheme, then gives you three ready-to-use tools: a Python helper, a Postman pre-request script, and a local self-test.

***

### 1. How it works <a href="#id-1-how-it-works" id="id-1-how-it-works"></a>

Encryption is a **hybrid** scheme — a symmetric key encrypts the body, and an asymmetric key protects that symmetric key:

* The **body** is encrypted with a one-time **AES-256-CBC** key (PKCS#7 padding).
* That one-time AES key is encrypted with an **RSA** public key (PKCS#1 v1.5 padding).
* A **HMAC-SHA1** over the IV and the ciphertext lets the server detect any tampering.

Two RSA key pairs, both exchanged during installation, are involved:

| Key pair            | Who holds the private key | Used to encrypt          |
| ------------------- | ------------------------- | ------------------------ |
| **Server** key pair | bunq                      | The **request** AES key  |
| **Client** key pair | You (the partner)         | The **response** AES key |

You obtain both public keys when you set up your installation:

* **Server public key** — the `server_public_key` value returned by `POST /v1/installation`. You use it to encrypt the AES key for a **request**.
* **Client public key** — the RSA public key *you* submit in `POST /v1/installation`. bunq uses it to encrypt the AES key for a **response**, so you decrypt responses with your own **client private key**.

> Keep your client private key secret and persisted — it is the only key that can decrypt an encrypted response.

### &#x20;2. Encrypting a request

Generate fresh random material for **every** request — never reuse an AES key or IV.

1. **Serialize** the request body to its final JSON string. (For endpoints with no body, such as `primary-account-number`, use an empty string — the headers are still required.)
2. **Generate a random AES key** — 32 bytes (256 bits).
3. **Generate a random IV** — 16 bytes (128 bits).
4. **Encrypt the body** with `AES-256-CBC` (PKCS#7 padding) using that key and IV. The resulting **raw binary** ciphertext is your new HTTP body.
5. **Encrypt the AES key** by RSA-encrypting the 32-byte key with the **bunq server public key** (PKCS#1 v1.5 padding).
6. **Compute the HMAC**: `HMAC-SHA1` over `IV || ciphertext`, keyed with the **raw AES key**. Note the HMAC is over the *ciphertext*, not the plaintext.
7. **Set the three headers** below (each base64-encoded) and send the raw ciphertext as the body.

#### Request headers <a href="#request-headers" id="request-headers"></a>

| Header                          | Value (base64-encoded)                |
| ------------------------------- | ------------------------------------- |
| `X-Bunq-Client-Encryption-Iv`   | The 16-byte IV                        |
| `X-Bunq-Client-Encryption-Key`  | The RSA-encrypted AES key             |
| `X-Bunq-Client-Encryption-Hmac` | The HMAC-SHA1 of `IV \|\| ciphertext` |

All three headers must be present and valid; their presence is what marks the request as encrypted.

#### Ordering relative to request signing <a href="#ordering-relative-to-request-signing" id="ordering-relative-to-request-signing"></a>

Compute your request **signature over the encrypted body** (the ciphertext), exactly as you would over a plain body. In other words: encrypt first, then sign the resulting body.

### 3. Decrypting a response <a href="#id-3-decrypting-a-response" id="id-3-decrypting-a-response"></a>

bunq encrypts the response body **only if your request was encrypted** and the body is non-empty. When it does, the response carries three server headers mirroring the request ones:

| Header                          | Value (base64-encoded)                                     |
| ------------------------------- | ---------------------------------------------------------- |
| `X-Bunq-Server-Encryption-Iv`   | The 16-byte IV bunq generated                              |
| `X-Bunq-Server-Encryption-Key`  | The AES key, RSA-encrypted with **your client public key** |
| `X-Bunq-Server-Encryption-Hmac` | The HMAC-SHA1 of `IV \|\| ciphertext`                      |

The response body is **raw binary** AES-256-CBC ciphertext. To decrypt:

1. Detect encryption by the presence of `X-Bunq-Server-Encryption-Key`.
2. **Base64-decode** the three headers.
3. **RSA-decrypt** `X-Bunq-Server-Encryption-Key` with your **client private key** to recover the 32-byte AES key.
4. *(Recommended)* **Verify the HMAC**: recompute `HMAC-SHA1(IV || ciphertext)` with the recovered AES key and compare against `X-Bunq-Server-Encryption-Hmac`.
5. **AES-256-CBC-decrypt** the body with the recovered AES key and the decoded IV.

### 4. Algorithm reference

| Parameter         | Value                                                       |
| ----------------- | ----------------------------------------------------------- |
| Symmetric cipher  | `AES-256-CBC` — 32-byte key, 16-byte IV, PKCS#7 padding     |
| Asymmetric cipher | RSA, PKCS#1 v1.5 padding                                    |
| HMAC              | `HMAC-SHA1`, keyed with the raw AES key                     |
| HMAC input        | `IV \|\| ciphertext` (IV bytes followed by body ciphertext) |
| Header encoding   | Base64                                                      |
| Body encoding     | Raw binary ciphertext (**not** base64)                      |

***

### 5. Error responses <a href="#id-5-error-responses" id="id-5-error-responses"></a>

| HTTP | Message                                   | Cause                                                         |
| ---- | ----------------------------------------- | ------------------------------------------------------------- |
| 400  | `Encryption required for this endpoint.`  | Card endpoint called without encryption headers.              |
| 400  | `The "<header>" header is missing…`       | One of the three headers absent.                              |
| 400  | `The "<header>" header … invalid format.` | Header value is not valid base64.                             |
| 400  | `Failed to decrypt encryption key.`       | AES key not RSA-encrypted with the correct server public key. |
| 400  | `Invalid HMAC.`                           | HMAC mismatch — wrong key/IV/body, or tampering.              |
| 400  | `Failed to decrypt body.`                 | Body ciphertext could not be AES-decrypted.                   |

***

### 6. Tooling <a href="#id-6-tooling" id="id-6-tooling"></a>

Three drop-in tools implement the request side of the scheme exactly as the server expects.

#### 6.1 Python helper <a href="#id-61-python-helper" id="id-61-python-helper"></a>

Encrypts a body and prints the three headers; writes the ciphertext to a file to attach as a **binary** body in Postman or `curl`.

```bash
pip install cryptography

# Get server_public_key from the POST /v1/installation response, save as server_public.pem
python3 encrypt_request.py \
    --body body.json \
    --public-key server_public.pem \
    --out-body encrypted_body.bin

# Or pass the body inline:
python3 encrypt_request.py --body-inline '{"field":"value"}' --public-key server_public.pem
```

<details>

<summary><code>encrypt_request.py</code></summary>

```python
#!/usr/bin/env python3
"""
bunq application-layer request encryption helper.

Takes a plaintext JSON body plus the installation's server public key, and produces:
  1. The three encryption headers (base64 text) to send.
  2. The encrypted request body, written to a file, to attach as a *binary* body.

Scheme (exactly what the bunq API expects):
  1. Generate a random 16-byte IV and a random 32-byte AES-256 key.
  2. AES-256-CBC encrypt the JSON body (PKCS#7 padding). The ciphertext IS the body.
  3. RSA-encrypt the AES key with the server public key (PKCS#1 v1.5 padding).
  4. HMAC-SHA1 over (IV + encrypted_body), keyed with the AES key.
  5. Send, base64-encoded:
       X-Bunq-Client-Encryption-Iv    = base64(iv)
       X-Bunq-Client-Encryption-Key   = base64(rsa_encrypt(aes_key))
       X-Bunq-Client-Encryption-Hmac  = base64(hmac)

Requires: pip install cryptography
"""

import argparse
import base64
import hmac
import hashlib
import json
import os
import sys

from cryptography.hazmat.primitives import padding as symmetric_padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key

IV_LENGTH_BYTE = 16
AES_KEY_LENGTH_BYTE = 32  # AES-256
HEADER_IV = "X-Bunq-Client-Encryption-Iv"
HEADER_KEY = "X-Bunq-Client-Encryption-Key"
HEADER_HMAC = "X-Bunq-Client-Encryption-Hmac"


def encrypt_body(plaintext_body_string, aes_key, iv):
    """AES-256-CBC encrypt the body with PKCS#7 padding; returns raw ciphertext bytes."""
    padder = symmetric_padding.PKCS7(algorithms.AES.block_size).padder()
    padded = padder.update(plaintext_body_string.encode("utf-8")) + padder.finalize()

    encryptor = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).encryptor()
    return encryptor.update(padded) + encryptor.finalize()


def encrypt_aes_key(aes_key, public_key_pem_bytes):
    """RSA-encrypt the AES key with PKCS#1 v1.5 padding (what the bunq server expects)."""
    public_key = load_pem_public_key(public_key_pem_bytes)
    return public_key.encrypt(aes_key, asymmetric_padding.PKCS1v15())


def compute_hmac(aes_key, iv, encrypted_body):
    """HMAC-SHA1 over (iv + encrypted_body), keyed with the AES key."""
    return hmac.new(aes_key, iv + encrypted_body, hashlib.sha1).digest()


def build_encrypted_request(plaintext_body_string, public_key_pem_bytes):
    iv = os.urandom(IV_LENGTH_BYTE)
    aes_key = os.urandom(AES_KEY_LENGTH_BYTE)

    encrypted_body = encrypt_body(plaintext_body_string, aes_key, iv)
    encrypted_aes_key = encrypt_aes_key(aes_key, public_key_pem_bytes)
    request_hmac = compute_hmac(aes_key, iv, encrypted_body)

    all_header = {
        HEADER_IV: base64.b64encode(iv).decode("ascii"),
        HEADER_KEY: base64.b64encode(encrypted_aes_key).decode("ascii"),
        HEADER_HMAC: base64.b64encode(request_hmac).decode("ascii"),
    }
    return all_header, encrypted_body


def read_body_string(args):
    if args.body_inline is not None:
        raw = args.body_inline
    elif args.body is not None:
        with open(args.body, "r", encoding="utf-8") as body_file:
            raw = body_file.read()
    else:
        raw = prompt_body_string()

    # Endpoints like primary-account-number are POSTs with no request fields:
    # an empty body is valid and must still be encrypted (headers are mandatory).
    if raw.strip() == "":
        return ""

    # Validate + normalise so we never encrypt malformed JSON.
    return json.dumps(json.loads(raw), separators=(",", ":"))


def read_lines_until_eof(prompt_message):
    """Read a multi-line paste from the terminal until Ctrl-D (EOF)."""
    print(prompt_message)
    return sys.stdin.read()


def prompt_body_string():
    return read_lines_until_eof(
        "\nPaste the plaintext JSON request body, then press Enter and Ctrl-D."
        "\n(For endpoints with no request body, e.g. primary-account-number, "
        "just press Ctrl-D on an empty line.):",
    )


def prompt_public_key_bytes():
    raw = read_lines_until_eof(
        "\nPaste the server public key PEM "
        "(-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----), "
        "then press Enter and Ctrl-D:",
    )
    return normalise_pem(raw).encode("utf-8")


def normalise_pem(raw):
    """Turn a JSON-escaped key (literal \\n / \\r) back into a real PEM."""
    if "\\n" in raw or "\\r" in raw:
        return raw.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
    return raw


def read_public_key_bytes(args):
    if args.public_key is not None:
        with open(args.public_key, "r", encoding="utf-8") as key_file:
            return normalise_pem(key_file.read()).encode("utf-8")
    return prompt_public_key_bytes()


def main():
    parser = argparse.ArgumentParser(
        description="Encrypt a bunq API request body and print the encryption headers.",
    )
    parser.add_argument("--body", help="Path to a JSON file with the plaintext body.")
    parser.add_argument("--body-inline", help="Inline JSON string for the plaintext body.")
    parser.add_argument("--public-key", help="Path to the installation's server public key PEM.")
    parser.add_argument(
        "--out-body",
        default="encrypted_body.bin",
        help="Where to write the encrypted (binary) body. Default: encrypted_body.bin",
    )
    args = parser.parse_args()

    plaintext_body_string = read_body_string(args)
    public_key_pem_bytes = read_public_key_bytes(args)

    all_header, encrypted_body = build_encrypted_request(
        plaintext_body_string,
        public_key_pem_bytes,
    )

    with open(args.out_body, "wb") as out_file:
        out_file.write(encrypted_body)

    print("Send these headers:\n")
    for name, value in all_header.items():
        print("{}: {}".format(name, value))

    print(
        "\nRequest body written to: {}".format(args.out_body)
        + "\nIn Postman: Body -> binary -> select this file. Do NOT paste it as text."
    )


if __name__ == "__main__":
    sys.exit(main())
```

</details>

**In Postman:** paste the three headers into **Headers**, set **Body → binary** and select `encrypted_body.bin`. The body is raw ciphertext — do **not** paste it as text.

#### 6.2 Postman pre-request script (no Python) <a href="#id-62-postman-pre-request-script-no-python" id="id-62-postman-pre-request-script-no-python"></a>

`postman_pre_request.js` does the same encryption inside Postman, so you type plaintext JSON and Postman encrypts it on send. It uses only the built-in `crypto-js` (AES + HMAC) and native `BigInt` (RSA PKCS#1 v1.5) — nothing to install.

**Setup:**

1. Paste the script into the request's (or collection's) **Pre-request Script** tab.
2. Set a variable **`public_key_client_fmt`** to the installation's `server_public_key` PEM. A JSON-escaped value with literal `\n` is accepted.
3. Set the request **Body → raw** and type your **plaintext JSON** (leave empty for no-body endpoints).

On send, the script sets the three `X-Bunq-Client-Encryption-*` headers and replaces the body with the raw ciphertext (via an auto-managed `{{encryptedBody}}` variable).

> **Byte-transport caveat.** Postman serialises a script-set body as UTF-8, which can corrupt raw ciphertext bytes (> 0x7F) on some versions. The crypto is verified correct byte-for-byte — so if you hit `400 Invalid HMAC` or `Failed to decrypt body`, it is this transport issue, not the crypto. Fall back to the Python helper's `binary` body, which is bulletproof.

<details>

<summary><code>postman_pre_request.js</code></summary>

```javascript
/**
 * bunq application-layer request encryption — Postman PRE-REQUEST script.
 *
 * Mirrors the server-side decryptor:
 *   - AES-256-CBC, PKCS#7 padding, 16-byte IV, 32-byte key.
 *   - HMAC-SHA1 over (IV + RAW ciphertext), keyed with the AES key.
 *   - AES key RSA-encrypted with the server public key, PKCS#1 v1.5 padding.
 *   - All three header values are base64.
 *   - The HTTP body on the wire is the RAW AES ciphertext bytes.
 *
 * No external libraries: AES/HMAC via built-in crypto-js, RSA via native BigInt.
 *
 * SETUP:
 *   1. Paste this into the request's (or collection's) "Pre-request Script" tab.
 *   2. Variable `public_key_client_fmt` must hold the server_public_key PEM.
 *      A JSON-escaped key with literal \n is accepted.
 *   3. Request Body -> "raw" -> type PLAINTEXT JSON (empty for no-body endpoints).
 */

const CryptoJS = require('crypto-js');

const IV_LENGTH_BYTE = 16;
const AES_KEY_LENGTH_BYTE = 32; // AES-256
const HEADER_IV = 'X-Bunq-Client-Encryption-Iv';
const HEADER_KEY = 'X-Bunq-Client-Encryption-Key';
const HEADER_HMAC = 'X-Bunq-Client-Encryption-Hmac';
const BODY_VARIABLE_NAME = 'encryptedBody';
const PUBLIC_KEY_VARIABLE_NAME = 'public_key_client_fmt';

main();

function main() {
    const publicKeyPem = readPublicKeyPem();
    const plaintextBody = readPlaintextBody();

    const ivWa = CryptoJS.lib.WordArray.random(IV_LENGTH_BYTE);
    const keyWa = CryptoJS.lib.WordArray.random(AES_KEY_LENGTH_BYTE);

    const ciphertextWa = encryptBody(plaintextBody, keyWa, ivWa);
    const encryptedKeyB64 = rsaEncryptPkcs1v15(wordArrayToBytes(keyWa), publicKeyPem);
    const hmacWa = computeHmac(keyWa, ivWa, ciphertextWa);

    pm.variables.set(BODY_VARIABLE_NAME, CryptoJS.enc.Latin1.stringify(ciphertextWa));
    pm.request.body.update({ mode: 'raw', raw: '{{' + BODY_VARIABLE_NAME + '}}' });

    upsertHeader(HEADER_IV, CryptoJS.enc.Base64.stringify(ivWa));
    upsertHeader(HEADER_KEY, encryptedKeyB64);
    upsertHeader(HEADER_HMAC, CryptoJS.enc.Base64.stringify(hmacWa));

    console.log('[bunq-encrypt] ' + HEADER_IV + ': ' + CryptoJS.enc.Base64.stringify(ivWa));
    console.log('[bunq-encrypt] ' + HEADER_KEY + ': ' + encryptedKeyB64);
    console.log('[bunq-encrypt] ' + HEADER_HMAC + ': ' + CryptoJS.enc.Base64.stringify(hmacWa));
    console.log('[bunq-encrypt] ciphertext bytes: ' + (ciphertextWa.sigBytes));
}

function readPublicKeyPem() {
    const raw = pm.variables.get(PUBLIC_KEY_VARIABLE_NAME);

    if (raw === undefined || raw === null || String(raw).trim() === '') {
        throw new Error('Set the "' + PUBLIC_KEY_VARIABLE_NAME + '" variable to the public-key PEM.');
    }

    return normalisePem(String(raw));
}

function normalisePem(raw) {
    if (raw.indexOf('\\n') !== -1 || raw.indexOf('\\r') !== -1) {
        return raw.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
    }
    return raw;
}

function readPlaintextBody() {
    const rawBody = (pm.request.body && pm.request.body.raw) ? pm.request.body.raw : '';

    if (rawBody.trim() === '') {
        return '';
    }

    try {
        return JSON.stringify(JSON.parse(rawBody));
    } catch (error) {
        throw new Error('Request body is not valid JSON: ' + error.message);
    }
}

function encryptBody(plaintextBody, keyWa, ivWa) {
    const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plaintextBody), keyWa, {
        iv: ivWa,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7,
    });

    return encrypted.ciphertext;
}

function computeHmac(keyWa, ivWa, ciphertextWa) {
    const hmacInput = ivWa.clone().concat(ciphertextWa);

    return CryptoJS.HmacSHA1(hmacInput, keyWa);
}

function upsertHeader(name, value) {
    pm.request.headers.upsert({ key: name, value: value });
}

/* RSAES-PKCS1-v1_5 encryption (RFC 8017 7.2.1) using native BigInt. */

function rsaEncryptPkcs1v15(messageBytes, publicKeyPem) {
    const publicKey = parseRsaPublicKey(publicKeyPem);
    const k = bigIntByteLength(publicKey.n);

    if (messageBytes.length > k - 11) {
        throw new Error('Message too long for RSA key.');
    }

    const em = buildPkcs1v15Padding(messageBytes, k);
    const cipher = modPow(bytesToBigInt(em), publicKey.e, publicKey.n);
    const cipherBytes = bigIntToBytes(cipher, k);

    return CryptoJS.enc.Base64.stringify(bytesToWordArray(cipherBytes));
}

// EM = 0x00 || 0x02 || PS (nonzero random) || 0x00 || M
function buildPkcs1v15Padding(messageBytes, k) {
    const psLength = k - messageBytes.length - 3;
    const em = new Uint8Array(k);
    em[0] = 0x00;
    em[1] = 0x02;

    for (let i = 0; i < psLength; i++) {
        em[2 + i] = randomNonZeroByte();
    }

    em[2 + psLength] = 0x00;

    for (let i = 0; i < messageBytes.length; i++) {
        em[3 + psLength + i] = messageBytes[i];
    }

    return em;
}

function randomNonZeroByte() {
    const bytes = wordArrayToBytes(CryptoJS.lib.WordArray.random(1));

    if (bytes[0] === 0) {
        return 0x01;
    }
    return bytes[0];
}

// Auto-detects SPKI ("PUBLIC KEY") vs PKCS#1 ("RSA PUBLIC KEY").
function parseRsaPublicKey(pem) {
    const der = pemToDerBytes(pem);
    const outer = readTlv(der, 0);
    const firstChild = readTlv(der, outer.contentStart);

    if (firstChild.tag === 0x02) {
        return readRsaIntegerPair(der, outer.contentStart);
    } else {
        const bitString = readTlv(der, firstChild.contentEnd);
        const innerSequence = readTlv(der, bitString.contentStart + 1);

        return readRsaIntegerPair(der, innerSequence.contentStart);
    }
}

function readRsaIntegerPair(der, position) {
    const nTlv = readTlv(der, position);
    const eTlv = readTlv(der, nTlv.contentEnd);

    return {
        n: bytesToBigInt(der.slice(nTlv.contentStart, nTlv.contentEnd)),
        e: bytesToBigInt(der.slice(eTlv.contentStart, eTlv.contentEnd)),
    };
}

function readTlv(bytes, position) {
    const tag = bytes[position];
    let cursor = position + 1;
    let length = bytes[cursor];
    cursor += 1;

    if (length & 0x80) {
        const lengthByteCount = length & 0x7f;
        length = 0;

        for (let i = 0; i < lengthByteCount; i++) {
            length = (length << 8) | bytes[cursor];
            cursor += 1;
        }
    }

    return { tag: tag, contentStart: cursor, contentEnd: cursor + length };
}

function pemToDerBytes(pem) {
    const base64Body = pem
        .replace(/-----BEGIN [^-]+-----/, '')
        .replace(/-----END [^-]+-----/, '')
        .replace(/\s+/g, '');

    return wordArrayToBytes(CryptoJS.enc.Base64.parse(base64Body));
}

/* BigInt / byte helpers. */

function modPow(base, exponent, modulus) {
    let result = 1n;
    let workingBase = base % modulus;
    let workingExponent = exponent;

    while (workingExponent > 0n) {
        if (workingExponent & 1n) {
            result = (result * workingBase) % modulus;
        }
        workingExponent >>= 1n;
        workingBase = (workingBase * workingBase) % modulus;
    }

    return result;
}

function bytesToBigInt(bytes) {
    let value = 0n;

    for (let i = 0; i < bytes.length; i++) {
        value = (value << 8n) | BigInt(bytes[i]);
    }

    return value;
}

function bigIntToBytes(value, length) {
    const bytes = new Uint8Array(length);
    let working = value;

    for (let i = length - 1; i >= 0; i--) {
        bytes[i] = Number(working & 0xffn);
        working >>= 8n;
    }

    return bytes;
}

function bigIntByteLength(value) {
    let length = 0;
    let working = value;

    while (working > 0n) {
        working >>= 8n;
        length += 1;
    }

    return length;
}

function wordArrayToBytes(wordArray) {
    const words = wordArray.words;
    const sigBytes = wordArray.sigBytes;
    const bytes = new Uint8Array(sigBytes);

    for (let i = 0; i < sigBytes; i++) {
        bytes[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
    }

    return bytes;
}

function bytesToWordArray(bytes) {
    const words = [];

    for (let i = 0; i < bytes.length; i++) {
        words[i >>> 2] |= bytes[i] << (24 - (i % 4) * 8);
    }

    return CryptoJS.lib.WordArray.create(words, bytes.length);
}
```

</details>

#### 6.3 Verify locally (self-test) <a href="#id-63-verify-locally-self-test" id="id-63-verify-locally-self-test"></a>

`selftest.sh` proves the encryption is correct **without hitting the API**: it generates a throwaway RSA keypair (standing in for the installation server key), encrypts a sample body, then decrypts it the exact way the bunq server does and checks the round-trip.

```bash
bash selftest.sh
```

<details>

<summary><code>selftest.sh</code></summary>

```shellscript
/**
 * bunq application-layer request encryption — Postman PRE-REQUEST script.
 *
 * Mirrors the server-side decryptor:
 *   - AES-256-CBC, PKCS#7 padding, 16-byte IV, 32-byte key.
 *   - HMAC-SHA1 over (IV + RAW ciphertext), keyed with the AES key.
 *   - AES key RSA-encrypted with the server public key, PKCS#1 v1.5 padding.
 *   - All three header values are base64.
 *   - The HTTP body on the wire is the RAW AES ciphertext bytes.
 *
 * No external libraries: AES/HMAC via built-in crypto-js, RSA via native BigInt.
 *
 * SETUP:
 *   1. Paste this into the request's (or collection's) "Pre-request Script" tab.
 *   2. Variable `public_key_client_fmt` must hold the server_public_key PEM.
 *      A JSON-escaped key with literal \n is accepted.
 *   3. Request Body -> "raw" -> type PLAINTEXT JSON (empty for no-body endpoints).
 */

const CryptoJS = require('crypto-js');

const IV_LENGTH_BYTE = 16;
const AES_KEY_LENGTH_BYTE = 32; // AES-256
const HEADER_IV = 'X-Bunq-Client-Encryption-Iv';
const HEADER_KEY = 'X-Bunq-Client-Encryption-Key';
const HEADER_HMAC = 'X-Bunq-Client-Encryption-Hmac';
const BODY_VARIABLE_NAME = 'encryptedBody';
const PUBLIC_KEY_VARIABLE_NAME = 'public_key_client_fmt';

main();

function main() {
    const publicKeyPem = readPublicKeyPem();
    const plaintextBody = readPlaintextBody();

    const ivWa = CryptoJS.lib.WordArray.random(IV_LENGTH_BYTE);
    const keyWa = CryptoJS.lib.WordArray.random(AES_KEY_LENGTH_BYTE);

    const ciphertextWa = encryptBody(plaintextBody, keyWa, ivWa);
    const encryptedKeyB64 = rsaEncryptPkcs1v15(wordArrayToBytes(keyWa), publicKeyPem);
    const hmacWa = computeHmac(keyWa, ivWa, ciphertextWa);

    pm.variables.set(BODY_VARIABLE_NAME, CryptoJS.enc.Latin1.stringify(ciphertextWa));
    pm.request.body.update({ mode: 'raw', raw: '{{' + BODY_VARIABLE_NAME + '}}' });

    upsertHeader(HEADER_IV, CryptoJS.enc.Base64.stringify(ivWa));
    upsertHeader(HEADER_KEY, encryptedKeyB64);
    upsertHeader(HEADER_HMAC, CryptoJS.enc.Base64.stringify(hmacWa));

    console.log('[bunq-encrypt] ' + HEADER_IV + ': ' + CryptoJS.enc.Base64.stringify(ivWa));
    console.log('[bunq-encrypt] ' + HEADER_KEY + ': ' + encryptedKeyB64);
    console.log('[bunq-encrypt] ' + HEADER_HMAC + ': ' + CryptoJS.enc.Base64.stringify(hmacWa));
    console.log('[bunq-encrypt] ciphertext bytes: ' + (ciphertextWa.sigBytes));
}

function readPublicKeyPem() {
    const raw = pm.variables.get(PUBLIC_KEY_VARIABLE_NAME);

    if (raw === undefined || raw === null || String(raw).trim() === '') {
        throw new Error('Set the "' + PUBLIC_KEY_VARIABLE_NAME + '" variable to the public-key PEM.');
    }

    return normalisePem(String(raw));
}

function normalisePem(raw) {
    if (raw.indexOf('\\n') !== -1 || raw.indexOf('\\r') !== -1) {
        return raw.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
    }
    return raw;
}

function readPlaintextBody() {
    const rawBody = (pm.request.body && pm.request.body.raw) ? pm.request.body.raw : '';

    if (rawBody.trim() === '') {
        return '';
    }

    try {
        return JSON.stringify(JSON.parse(rawBody));
    } catch (error) {
        throw new Error('Request body is not valid JSON: ' + error.message);
    }
}

function encryptBody(plaintextBody, keyWa, ivWa) {
    const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plaintextBody), keyWa, {
        iv: ivWa,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7,
    });

    return encrypted.ciphertext;
}

function computeHmac(keyWa, ivWa, ciphertextWa) {
    const hmacInput = ivWa.clone().concat(ciphertextWa);

    return CryptoJS.HmacSHA1(hmacInput, keyWa);
}

function upsertHeader(name, value) {
    pm.request.headers.upsert({ key: name, value: value });
}

/* RSAES-PKCS1-v1_5 encryption (RFC 8017 7.2.1) using native BigInt. */

function rsaEncryptPkcs1v15(messageBytes, publicKeyPem) {
    const publicKey = parseRsaPublicKey(publicKeyPem);
    const k = bigIntByteLength(publicKey.n);

    if (messageBytes.length > k - 11) {
        throw new Error('Message too long for RSA key.');
    }

    const em = buildPkcs1v15Padding(messageBytes, k);
    const cipher = modPow(bytesToBigInt(em), publicKey.e, publicKey.n);
    const cipherBytes = bigIntToBytes(cipher, k);

    return CryptoJS.enc.Base64.stringify(bytesToWordArray(cipherBytes));
}

// EM = 0x00 || 0x02 || PS (nonzero random) || 0x00 || M
function buildPkcs1v15Padding(messageBytes, k) {
    const psLength = k - messageBytes.length - 3;
    const em = new Uint8Array(k);
    em[0] = 0x00;
    em[1] = 0x02;

    for (let i = 0; i < psLength; i++) {
        em[2 + i] = randomNonZeroByte();
    }

    em[2 + psLength] = 0x00;

    for (let i = 0; i < messageBytes.length; i++) {
        em[3 + psLength + i] = messageBytes[i];
    }

    return em;
}

function randomNonZeroByte() {
    const bytes = wordArrayToBytes(CryptoJS.lib.WordArray.random(1));

    if (bytes[0] === 0) {
        return 0x01;
    }
    return bytes[0];
}

// Auto-detects SPKI ("PUBLIC KEY") vs PKCS#1 ("RSA PUBLIC KEY").
function parseRsaPublicKey(pem) {
    const der = pemToDerBytes(pem);
    const outer = readTlv(der, 0);
    const firstChild = readTlv(der, outer.contentStart);

    if (firstChild.tag === 0x02) {
        return readRsaIntegerPair(der, outer.contentStart);
    } else {
        const bitString = readTlv(der, firstChild.contentEnd);
        const innerSequence = readTlv(der, bitString.contentStart + 1);

        return readRsaIntegerPair(der, innerSequence.contentStart);
    }
}

function readRsaIntegerPair(der, position) {
    const nTlv = readTlv(der, position);
    const eTlv = readTlv(der, nTlv.contentEnd);

    return {
        n: bytesToBigInt(der.slice(nTlv.contentStart, nTlv.contentEnd)),
        e: bytesToBigInt(der.slice(eTlv.contentStart, eTlv.contentEnd)),
    };
}

function readTlv(bytes, position) {
    const tag = bytes[position];
    let cursor = position + 1;
    let length = bytes[cursor];
    cursor += 1;

    if (length & 0x80) {
        const lengthByteCount = length & 0x7f;
        length = 0;

        for (let i = 0; i < lengthByteCount; i++) {
            length = (length << 8) | bytes[cursor];
            cursor += 1;
        }
    }

    return { tag: tag, contentStart: cursor, contentEnd: cursor + length };
}

function pemToDerBytes(pem) {
    const base64Body = pem
        .replace(/-----BEGIN [^-]+-----/, '')
        .replace(/-----END [^-]+-----/, '')
        .replace(/\s+/g, '');

    return wordArrayToBytes(CryptoJS.enc.Base64.parse(base64Body));
}

/* BigInt / byte helpers. */

function modPow(base, exponent, modulus) {
    let result = 1n;
    let workingBase = base % modulus;
    let workingExponent = exponent;

    while (workingExponent > 0n) {
        if (workingExponent & 1n) {
            result = (result * workingBase) % modulus;
        }
        workingExponent >>= 1n;
        workingBase = (workingBase * workingBase) % modulus;
    }

    return result;
}

function bytesToBigInt(bytes) {
    let value = 0n;

    for (let i = 0; i < bytes.length; i++) {
        value = (value << 8n) | BigInt(bytes[i]);
    }

    return value;
}

function bigIntToBytes(value, length) {
    const bytes = new Uint8Array(length);
    let working = value;

    for (let i = length - 1; i >= 0; i--) {
        bytes[i] = Number(working & 0xffn);
        working >>= 8n;
    }

    return bytes;
}

function bigIntByteLength(value) {
    let length = 0;
    let working = value;

    while (working > 0n) {
        working >>= 8n;
        length += 1;
    }

    return length;
}

function wordArrayToBytes(wordArray) {
    const words = wordArray.words;
    const sigBytes = wordArray.sigBytes;
    const bytes = new Uint8Array(sigBytes);

    for (let i = 0; i < sigBytes; i++) {
        bytes[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
    }

    return bytes;
}

function bytesToWordArray(bytes) {
    const words = [];

    for (let i = 0; i < bytes.length; i++) {
        words[i >>> 2] |= bytes[i] << (24 - (i % 4) * 8);
    }

    return CryptoJS.lib.WordArray.create(words, bytes.length);
}
```

</details>

***

### 7. Checklist <a href="#id-7-checklist" id="id-7-checklist"></a>

* [ ] Client RSA key pair generated; public key submitted at installation; private key stored securely.
* [ ] Server public key captured from the `POST /v1/installation` response.
* [ ] Fresh 32-byte AES key and 16-byte IV per request — never reused.
* [ ] Body encrypted with AES-256-CBC (PKCS#7); **raw** ciphertext sent as the body.
* [ ] AES key RSA-encrypted (PKCS#1 v1.5) with the server public key.
* [ ] HMAC-SHA1 over `IV || ciphertext`, keyed with the AES key.
* [ ] All three `X-Bunq-Client-Encryption-*` headers base64-encoded.
* [ ] Request signature computed over the ciphertext.
* [ ] Response decryption path handles the `X-Bunq-Server-Encryption-*` headers
