Skip to content
Giorgio edited this page Apr 6, 2022 · 3 revisions

Preface

Implementing EA support was... an adventure.

An adventure that took much longer than expected. I learned so much stuff, but by the same token I hit many roadblocks and dead ends. But by the end of it, I'm very much glad that it's over, and that I managed to accomplish what I set out to do.

The thing is, for the other platforms, they are either very simple (Steam, Twitch, Hypixel), or already have an amazing and brilliant dev community surrounding them that has done all the hard work, and whom I can ask questions if ever I don't understand something.

EA was different. In comparison, the third-party dev community looked more like a desert, with only a few GitHub projects dating back to 2019. I looked for ages for some sort of forum or Discord server, like the ones Riot and Epic have, but there doesn't seem to be any. (If there is and I missed it, or if you want to create one, please don't hesitate to invite me!)

The reason I chose EA next was because, supposedly, Origin uses XMPP according to many, many different sources, and there was even a Java implementation I could use to help me figure things out.

Turns out, although EA used XMPP a few years ago, they completely switched to some sort of proprietary binary protocol, and a really tough one to reverse-engineer at that. I could probably write a multi-part blog post detailing all the different steps and hoops I had to jump through to even begin to understand how it worked.

But enough about the process, let's get to the result.

Authentication

As usual, the first step is to prove that you are who you claim you are, and that is usually done using authentication.

For the legacy authentication used by Origin, take a look here. For my plugin however, I chose to implement the newer and simpler auth flow used by the new EA App, using the RemID Cookie.

It involves doing two HTTP requests, the first to get an auth code, and the second to turn the auth code into an access token.

Getting the auth code

Here is the request:

GET https://accounts.ea.com/connect/auth?client_id=JUNO_PC_CLIENT&response_type=code&nonce=1&pc_sign=eyJhdiI6InYxIiwiYnNuIjoiRzJDN002MyIsImdpZCI6Mzk4NzYsImhzbiI6IkFSUkFZMCIsIm1hYyI6IiQwMGZmOWJiNGMyYTAiLCJtaWQiOiI3NTc3MTg0MDY1MDM5NjIzNTkxIiwibXNuIjoiLkcyQzdNNjMuQ05DTUswMDA5NDAwMjYuIiwic3YiOiJ2MSIsInRzIjoiMjAyMi0yLTI3IDEzOjM1OjQyOjI0NiJ9.XPSVI2ksrbveN_FcG2ep_1QpqLphs-cWZRgcsSnIfsI
Headers:
  Cookie: remid=${remid}

The RemID cookie header is optional.

The response should be an HTTP 302 redirect. The redirect URL can be one of two URLs:

  • If the RemID cookie is valid, it will be a URL with the format qrc:/html/login_successful.html?code=${code}. If so, don't forget to get the new RemID from the set-cookie header, since the old one won't work anymore.
  • Otherwise, it will be a link such as https://signin.ea.com/p/juno/login?fid=${fid}. Opening it in a web browser will open the standard sign-in screen. Once you log in, it will try to redirect you to a qrc:/ url like the one above, but it will fail and log the URL to the console.

In both cases, you should get the auth code from the qrc url.

Note: The pc_sign is usually a new one for each sign in. If you split the string at the . character, the first part base64-decoded is some info about the program trying to log in, including a timestamp. The second part is some sort of cryptographic hash of the first one, that can only be generated by the EA app itself. Luckily, as long as the string is valid, it doesn't seem to expire, so I've been using the same pc_sign for a month without any issues.

Getting the access token

To use your auth code to get an access token, here is the request:

POST https://accounts.ea.com/connect/token
Headers:
  content-type: application/x-www-form-urlencoded
Body:
  grant_type=authorization_code&code=${code}&client_id=JUNO_PC_CLIENT&client_secret=4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te

If all goes well, you should get three tokens: an access token, a refresh token and an id token. Access tokens last 4 hours, but you can use the refresh token to get a new access token. I'm not sure if/when refresh tokens expire though.

Note: If security is a priority, the above endpoints support PKCE using the code_challenge, code_challenge_method and code_verifier body parameters.

Refreshing your token

Using a refresh token is quite similar to using an auth code:

POST https://accounts.ea.com/connect/token
Headers:
  content-type: application/x-www-form-urlencoded
Body:
  client_id=JUNO_PC_CLIENT&grant_type=refresh_token&refresh_token=${token}&client_secret=4mRLtYMb6vq9qglomWEaT4ChxsXWcyqbQpuBNfMPOYOiDmYYQmjuaBsF2Zp0RyVeWkfqhE9TuGgAw7te

The Websocket

The connection to EA's friend network happens over a WebSocket connection to wss://rtm.tnt-ea.com:8095/websocket.

When connecting, you must include a User-Agent header, otherwise the WebSocket won't respond. This is the one that the EA App uses:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) QtWebEngine/5.15.2 Chrome/83.0.4103.122 Safari/537.36 Origin/10.6.0.00000 EAApp/12.0.179.5090

All communication happens using binary frames.

Data types

Most numbers are encoded as Big-Endian VarInts. Here is an article on wiki.vg with some pseudocode on how to encode/decode them.

Strings are encoded as UTF-8, prefixed with the length of the string as a VarInt.

Packet format

Here is where things start to get messy. You'll notice I've made many guesses and assumptions about how things actually work. That's because my goal was not to completely reverse the friends protocol, but simply to connect and read the presences of friends. This guide works for that, but should only be taken as a starting point if you want to do anything more, such as use chat.

All packets that the client sends, as well as some of the ones that it receives, use the following format: (newlines added for readability)

[length of the rest of the packet, as a 32bit uint]
0A
[length of the rest of the packet, as a VarInt]
0A
[packet header string]
[packet type]
[body]

I like to call these "timestamp packets".

Both lengths start counting from the 0A after it (this means the UInt length will always be larger than the VarInt length).

At this point, anyone familiar with the websocket protocol spec should be slightly confused as to why EA chose to include the packet length, as all websocket packets include it in the header already. This would make sense if there was some sort or mechanism to split packets that are too large, but that doesn't seem to be the case.

Packet header string

c-${n}-${t}-${t}

Where n is a counter for the number of packets sent so far, and t is the timestamp at which it was sent. I don't know why it is sent twice, though.

Packets received

Not all packets received by the client follow that format, though. The beginning is the same, but it's at the second 0A that you can tell whether you're dealing with a timestamp packet or not. More specifically, instead of the second 0A, you might get any of these instead:

3A: non-timestamp packet, used for receiving presences and lack thereof
82: in-game invite
C2: "session changed"
72: eviction

If you log in on multiple Origin clients at once, the first one will have a popup saying "you logged in elsewhere" and force the user to log out. I think that is what the "session changed" buffer is, basically EA servers saying "please disconnect".

That being said, and luckily for us:

  • We don't actually have to disconnect, and we will still keep receiving presences. After a few hours though, the server will be fed up of waiting and send us the 72 buffer, and then disconnect us. We can reconnect just fine though.
  • Connecting to the websocket is not enough to cause EA to disconnect all other sessions. I'm not sure which packet triggers that to happen, but it shouldn't be any of the ones in this guide.

All in all, this feature does little to prevent us from connecting and pretending it doesn't exist.

Non-timestamp packets

For non-timestamp 3A packets, here is the structure:

[length of the rest of the packet, as a 32bit uint]
0A
[length of the rest of the packet, as a VarInt]
3A
[length of the rest of the packet, as a VarInt, again]
0A
[target user ID, as a string]
[packet type]
[body]

EA really do like to keep track of the length of things, huh.

Packet types

Here is the list of packet types I've encountered:

> F2 02  Auth request
< 7A     Auth response

> 12     Own presence request
> 9A 03  Friend presences request
< 12     Presence response
< 28     No presence (offline)

> D2 02  Friends list request
< 92 01  Friends list response

> 5A     Friend name request
< 62     Friend name response

> 2A     Send own status

> A2 01  Heartbeat

Packet bodies

Now, let's get to how the different bodies of packets are formatted.

Auth request

This is the first packet sent by the client, asking to connect and including the access token.

F2 02 (packet type)
[length of the rest of the packet, as a VarInt]
0A
[access token]
10 00 18 00 20 00 2A 06 6F 72 69 67 69 6E 30 04 3A
[{"clientType":"ClientWeb","version":"juno-spa-0.0.1-15146-80d8a20","integrations":"LoginV3"}, as a JSON string]

Remember, all strings are prefixed with their length as a VarInt.

If all goes well, you should get back an auth response.

Auth Response

7A
[length of the rest of the packet, as a VarInt]
1A
[length of the rest of the packet, as a VarInt]
0A
[user identifier string]
[rest of packet, don't really care]

I'm not making this up by the way, EA just really love putting the length of the packet a bunch of times. I don't have a good explanation either, other than they absolutely want to prevent a buffer overflow exploit at all costs...? I suppose including the length of the packet FIVE TIMES is one way of doing it ¯\(ツ)

The user identifier string is something like origin:XXXXXXXXX:YYYYYY, where XXXXXXXXX is your user ID.

The rest of the packet is a bunch of JSON objects and IDs, including stuff like the current version of the app, so nothing terribly useful. As I said, I'm not going for a complete reverse-engineer of the protocol.

Own presence request

This is to see what game you are playing, if you are logged in on Origin somewhere else.

12
19 12 17 0A
[own user ID]
12
"origin"

Friend presence request

This is to ask to receive all the current friend presences, as well as subscribing to receive all the future presence updates.

9A 03
00

Friend presence response

If the friend is offline, the response will be a packet type 28, with a few other random bytes as body.

If the friend is online, the response will be a packet type 12:

12
[Friend presence JSON]
1A
[ISO timestamp]
...

Let's take a look at the presence JSON.

The presence JSON

Here is the JSON of a friend who's online, but not in-game:

{
    "groupActivity_groupType": "",
    "groupActivity_groupGuid": "",
    "groupActivity_groupName": "",
    "groupActivity_groupSize": 16,
    "groupActivity_invited": false,
    "groupActivity_isPublic": false,
    "gameActivity_broadcastUrl": "",
    "gameActivity_gamePresence": "",
    "gameActivity_gameTitle": "",
    "gameActivity_multiplayerId": "",
    "gameActivity_productId": "",
    "gameActivity_richPresence": "",
    "gameActivity_invited": false,
    "gameActivity_joinable": false,
    "gameActivity_joinableInviteOnly": false,
    "gameActivity_isNull": true,
    "presence_status": "",
    "presence_availability": 1,
    "presence_invisible": false,
    "presence_null": false
}

And here is the JSON of a friend in a game of FIFA:

{
    "groupActivity_groupType": "",
    "groupActivity_groupGuid": "",
    "groupActivity_groupName": "",
    "groupActivity_groupSize": 16,
    "groupActivity_invited": false,
    "groupActivity_isPublic": false,
    "gameActivity_broadcastUrl": "",
    "gameActivity_gamePresence": "{\"data\":{\"presence\":\"\",\"session\":\"\"},\"version\":\"1.0\"}",
    "gameActivity_gameTitle": "FIFA 22",
    "gameActivity_multiplayerId": "196837",
    "gameActivity_productId": "Origin.OFR.50.0004567",
    "gameActivity_richPresence": "{\"data\":\"FUT en ligne 0-0 Cvr - MET, 1re période\",\"version\":\"1.0\"}",
    "gameActivity_invited": false,
    "gameActivity_joinable": false,
    "gameActivity_joinableInviteOnly": false,
    "gameActivity_isNull": false,
    "presence_status": "FIFA 22 FUT en ligne 0-0 Cvr - MET, 1re période",
    "presence_availability": 1,
    "presence_invisible": false,
    "presence_null": false
}

At first glance, it seems like there is a lot of data, but don't be fooled, as most of it is empty.

Another thing to note is that the rich presence data changes depending on the language of the friend's Origin client, making reliable data parsing difficult. For example, in French it is Cvr - MET, but in English it would be Cvr V MET, and who knows what it is in other languages.

For most platforms, the reason to have a bunch more fields than actual data is because the social component was written with one game in mind, and then lazily backported to support all the other games in the launcher. For example, on Epic, all games include some fields that are only used by Fortnite.

I thought Apex would be the reason for all these extra fields, and while it does use a few more such as gameActivity_joinable, that's about it. Most of these fields will always have the same value, for example groupActivity_groupSize will always be 16.

Other than that, gameActivity_productId is the game code (FIFA 22 in this case), and gameActivity_richPresence contains the rich presence.

Beyond this, there's not much more I can say that isn't in my code. For example. I did play around with Apex presences for an hour or two, but since I don't play the game I don't feel like I have a good enough understanding of how they work.

Acknowledgements

None :) This is all my original research!