The details of this article have been communicated to the bank, but after 6 months of silence, I’m assuming it is not an issue for them, and decided to release this (see timeline below).

I think they are breaking PSD2 regulation around strong authentication, but
I’m not an expert on that subject.

Introduction

       When I was a child, I had a 10€ allowance per month. I remember keeping a small paper with all those transactions, but also planning future investment. For example, I knew I had to save for 8 years to afford my driving license.

Fast forward 2017, student, new flat, and thus the start of the great accounting spreadsheet™.

After 6 years, it became The Humongous Accounting Spreadsheet™.
Turns out, a single spreadsheet is not the ideal tool to track your every expenses across multiple countries. So here we are, with My Own Accounting Tool®.

It is fancy-enough, auto-categorizes most transactions, and can display pretty graphs.

Problem is, I still have to record transactions manually.

  • When I’m lucky, It’s a curl gathered from Firefox (DevTools > copy as cURL).
  • For others, a wonky regex-based python script to parse statements.

My goal: automatically fetching transactions directly from my bank account. This should reduce input mistake and accounting errors. My bank should have an API right?

The official API

The bank seems to have a public API: https://developer.lcl.fr/.
But as far as I understand, one needs to sign an agreement with the authorities or something, before getting some kind of certificate to sign requests.. Not going down that path tonight!

This bank also offers a website, so unless it’s full SSR, they should have some API I can plug into.

The other API

A quick look at the network requests, and here we are: https://monespace.lcl.fr/api/*!

The most interesting routes seems to be:

  • https://monespace.lcl.fr/api/login
  • https://monespace.lcl.fr/api/login/keypad
  • https://monespace.lcl.fr/api/login/contract
  • https://monespace.lcl.fr/api/user/accounts?type=current&contract_id=XXXXXXXX
  • https://monespace.lcl.fr/api/user/<account-id>/transactions

Those should be enough to fetch my own banking information.

Step 1: Login

To access my own data, I need to login.
For some unknown reason, banks in France LOVE weird SeCuRe visual keypads.
This bank doesn’t deviate: a 6-digit pin is the only password you need.

This bank's keypad

First surprising element: no 2FA by default? This bank does provide one (prompt on a trusted device), but it is only required for a few specific operations. I tried login on a blank browser, on a phone, with a new IP, and still, only the 6-digit password.

⚠ When traveling abroad, I noticed 2FA was required on the web page once, logging in from an already trusted device.
Rented a VPN, and tried my script in a few locations in France and Europe, and 2FA was never required. Not sure of the heuristic they chose, but since I can login from an untrusted location, and untrusted device, seems weak.

The 2 important network requests during the login are:

  • https://monespace.lcl.fr/api/login
  • https://monespace.lcl.fr/api/login/keypad

When you load the page, a first GET request is sent to api/login/keypad.
Upon login, a POST request is sent to api/login.

⚠ I redacted some parts of the request samples. The reason is I don’t know what those are, and if they contain secrets I shall not share.

api/login/keypad GET request

{
    "keypad": "13236373539383433303XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
  • keypad: A long, apparently random, digit-only sequence (partially redacted).

api/login POST request

{
   "callingUrl" : "/connexion",
   "clientTimestamp" : 1692997262,
   "encryptedIdentifier" : false,
   "identifier" : "XXXXXXXXXXX",
   "keypad" : "030303939303XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
   "sessionId" : "00000000000000000000001"
}
  • clientTimestamp: timestamp of the request.
  • encryptedIdentifier: always false, not sure why. Maybe something for plain HTTP requests?
  • identifier: the customer number.
  • keypad: A long, digit-only sequence (partially redacted). Maybe a challenge response?
  • sessionId: some client-side value derived from the timestamp. Seems to accept all numerical values as long as it respects some format.

Digit mangling

A large random number received, some client-side process with a keypad, and a large random number sent back. Some kind of challenge-response? Not exactly.

The keypad parameter is composed of 2 parts:

  • 13236373539383433303: a sequence determining the order of the keys on the keypad.
  • XXXXXXXXX...: the random seed used to generate that order?

So what do my login request looks like with the code 011000 ?
"keypad": "030303939303XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

The repetition pattern looks familiar.

03 03 03 93 93 03 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
 0  0  0  1  1  0 ??

Yes, that’s the pin code, mangled digit by digit, and reversed. The mangling is a bit weird:

  • take the received keypad
  • reverse the string
  • parse digits, 2 by 2, as an hex value
  • take the last 10 pairs
  • interpret them as base-10
  • take the ascii char corresponding to the each value.
  • those are your keypad numbers

I spare you the JS handling the keypad, but here is the python code to login.

answer = get_json("https://monespace.lcl.fr/api/login/keypad")

keypad = answer['keypad']

# Weird mangling/obfuscation for the keypad values.
# The HEX digits, interpreted as base-10 are the keypad digits.
keys = [ chr(int(x, base=16)) for x in re.findall('..', keypad[::-1]) ][-10:]
seed = "".join([ chr(int(x, base=16)) for x in re.findall('..', keypad[::-1]) ][:-10])

password = input("Your 6 digit pin? ")
mangled = "".join([ str(keys.index(x)) for x in password ])
token = "".join([ str(hex(ord(x)))[2:] for x in (seed+mangled) ])[::-1]

payload = {
    'callingUrl': "/connexion",
    'encryptedIdentifier': False,
    'identifier': "<customer-id>",
    'keypad': token,
    'clientTimestamp': now,
    'sessionId': "<some-random-value>"
}

post_json("https://monespace.lcl.fr/api/login", payload)

Getting the transactions

Now that we are logged in, we want to list transactions.

  • Each transaction is tied to an account.
  • Each account is tied to a contract.
  • Each contract is tied to a user.

So to get my transactions, I need to get the contract, then get the account, and only then transactions.

The initial login request returns a few info:

{
    "accessToken": "<Bearer token>",
    "refreshToken": "<Refresh token>",
    "expiresAt": "<timestamp>",
    "multiFactorAuth": null,
    "userName": "<name>",
    "birthdate": "<birthdate>",
    "[...]"
    "contracts": [
        {
            "id": "<contract-id>",
            "[...]"
        }
    ],
}

As-is, the accessToken cannot be used to fetch transactions. Instead, it is used to get a second token. Token which authenticates requests made to a specific “contract”. I’m not sure how accounts are tied to “contracts”, but in my case, I have 1 contract tied to 1 account.

api/login/contract POST request

{
    "clientTimestamp": timestamp,
    "contractId": base64.b64encode(contract["id"].encode()).decode()[:-2]
}

Why is the contract ID base64 encoded? Maybe some code sharing with the user/accounts GET route?

api/login/contract response.

{
    "accessToken": "<another-token>",
    "refreshToken": "<refresh-token>",
    "expiresAt": "<timestamp>"
}

This access token can be used on 2 routes:

  • https://monespace.lcl.fr/api/user/accounts?type=current&contract_id=XXXXXXXX
  • https://monespace.lcl.fr/api/user/<account-id>/transactions

api/user/accounts GET request

This request takes 4 parameters:

  • type: the type of the contract/account to fetch?, Here set to current.
  • contract_id: the base64 encoded contract ID.
  • is_eligible_for_identity: false. Not sure what this is about.
  • include_aggregate_account: <boolean>

It returns some information about the fetched account:

{
    "total": "<balance-in-euro>",
    "accounts": [
        {
            "type": "current",
            "iban": "<the iban>",
            "amount": {
                "date": "2023-08-25T22:45:46.892+0200",
                "value": "<balance>",
                "currenty": "EUR"
            },
            "internal_id": "<internal-account-id>",
            "external_id": "<external-account-id>",
            "[...]"
        }
    ]
}

api/user/<account-id>/transactions GET request

This request takes 2 parameters:

  • contract_id: this time, the internal_id received in the previous request.
  • range: <int32>-<int32>. From-To range of transactions to fetch. 0 is the most recent transaction.
{
    "isFailover": "<boolean>",
    "accountTransactions": [
        {
            "label": "CB some shop",
            "booking_date_time": "1970-01-01T00:00:00.000Z",
            "is_accounted": "<boolean>",
            "are_details_available": "<boolean>",
            "amount": {
                "value": -5.32,
                "currency": "EUR"
            },
            "movement_code_type": "<code>",
            "nature": "<I/CARTE/VIREMENT SEPA RECU/PRELVT SEPA RECU XXX>"
        }
    ]
}
  • movement_code_type: not sure, sometimes absent, sometimes an int (like 948).
  • nature: seem to be a free-form field, as SEPA order text can be seen there.

Getting old transactions

The api/user/<account-id>/transactions request takes a range. But if this range contains any transaction older than 90 days, the request fails: 2FA is required to make such request.

Digging a bit, I found 2 other API routes:

  • api/user/documents/accounts_statements
  • api/user/documents/documents

Those routes have no limit on the dates.

WAIT, WHAT?

Yes, they do require 2FA to call https://monespace.lcl.fr/api/user/<account-id>/transactions for transactions older than 90 days, but PDF statements since the dawn of time? Sure, NO PROBLEM.

The returned values have this format:

[
    {
        "codsoufamdoc_1": "AST",
        "datprddoccli": "2020-12-02",
        "downloadToken": "<some-token>",
        "liblg_typdoc": "Some human-readable document title",
        "libsoufamdoc_1": "Some human-readable category"
    }
]

To download the PDF, a GET request with the downloadToken fetched in the previous request:

https://monespace.lcl.fr/api/user/documents/download?downloadToken=<token>

Final thoughts

No 2 factor authentication;

A 6-digit pin. Really?
Why isn’t 2FA enforced by default? Even my empty Twitter account is more secured.

Why is the pin code mangled?

Isn’t SSL enough to secure your payload?
This rot-13 like obfuscation really seems weak if that’s the worry.

Auth tokens remains valid for 21 days

The web session does auto-exit after ~30mn of inactivity.
But did you know the auth tokens remains valid for 21 days?

Anyway, I do have what I need to interoperate with my accounting application, and I can rest peacefully, knowing my personal information are safe 🙃.

All the information disclosed here are public, and freely accessible with any web browser. I was required to figure this out to build interoperability with my own software.

Disclosure timeline

  • 28-08-2023: found those weaknesses, documented them.
  • 01-09-2023: contacted the bank on twitter via private message to ask about this.
  • 04-09-2023: contacted the bank by email since the twitter message hasn’t been replied to.
  • 05-09-2023: received a twitter message saying “we received the email, we’ll reply”
  • 21-02-2024: No news. Same behavior observed. Published this article.