In a recent post we verified Firefox Sync end-to-end encryption. In this post we'll do the same exercise with Proton, which has a suite of E2E products such as Mail, Drive, etc.
There isn't much public documentation on Proton's protocols and clients. In this post we verify and document the data that is actually sent to Proton and how it is encrypted.
We also write a POC client that decrypts mail received from Proton, using only the OpenPGP and bcrypt libraries.
Just like Mozilla, Proton claims it does not have access to your data. Its security model has some similarities to Mozilla's:
Let's verify that.
When creating a Proton account, our browser issues a POST request to
/api/core/v4/keys/setup
, sending the following data to
Proton:
{
"KeySalt": "oU4K+rJCCoNeGBsxKkVkmA==",
"PrimaryKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxYYEZ89rthYJKwYBBAHaRw8BAQdA/Jc/va78lcj/8xcDfZzpdiRuWC4+rHrc\nZIEt9tKvbnD+CQMILb2ffbo5FrFgAAAAAAAAAAAAAAAAAAAAALDEUP0fxPJt\nP1EZxGWF8XTEmIkmhJgCiWN764/2Tf91HwflqZEq7zqJscsEs5u1vOamTRME\nms07bm90X2Zvcl9lbWFpbF91c2VAZG9tYWluLnRsZCA8bm90X2Zvcl9lbWFp\nbF91c2VAZG9tYWluLnRsZD7CwBEEExYKAIMFgmfPa7YDCwkHCZDC6Sx7B2qF\nuUUUAAAAAAAcACBzYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnQdJ6etFr\nE4AR1IEVHqNy68leL5otQZpxk2j5zEPTNYUDFQoIBBYAAgECGQECmwMCHgEW\nIQQUlZpBh1l0/HqD+83C6Sx7B2qFuQAALxsBAKr9tDOwpXLmXU9YwUIN2S5B\no+sylWFSA3E9p6Pfk5JbAQCY4JXAgoqqtmnpBADIQBhcqpjjgV2ZL3lVdj/y\nwndWCseLBGfPa7YSCisGAQQBl1UBBQEBB0B0nocmR5w6HS1GIBuhCmH2eFa/\n+hLRwkf5K+b/dnQ3RgMBCAf+CQMIp8ZNbcPWyMxgAAAAAAAAAAAAAAAAAAAA\nAEgqjPc3zuLe9wVsXVcdstRzoh08Bq1y8iQMXShGlk9FF3mi+IMB5T6GiN4J\nArj8P7VHrCeVbsK+BBgWCgBwBYJnz2u2CZDC6Sx7B2qFuUUUAAAAAAAcACBz\nYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnWBV9jAu/soaf/oQz+J05BR1g\npuIISpjc0TEhb7LAKd0CmwwWIQQUlZpBh1l0/HqD+83C6Sx7B2qFuQAAoDwA\n/jAVI8PirjGrw+2HGourdmXIJN2zZTlSqwoM/5+PBvHHAQDPCl2JoMNnNy59\n3Dn9hTM8njGbsoW8LRov1CO6OWBRCw==\n=fHDb\n-----END PGP PRIVATE KEY BLOCK-----\n",
"AddressKeys": [
{
"AddressID": "_EJsQ0MKKpME3DSTy8i1B5-1hDwjqR421TWsGy4na7Rj7lBDAwxvnVKF7JhpOZtm-psrNgOfBMJcSQKYBrrflg==",
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\nxYYEZ89rtxYJKwYBBAHaRw8BAQdAuFPeIyO496A9ohEYaBnQcOOZs0/CyaXp\nuaqhRNoWwqX+CQMIwWmFqunn+OVgAAAAAAAAAAAAAAAAAAAAALEooTUmAzyJ\no5G+jO8ci3ywMAl2IBGWtY04qETR5BCJJVTIA8OBZ6BKP3TgmKimuxf/FNzI\nqs0xVGVzdDEyU29ycnkxMkBwcm90b24ubWUgPFRlc3QxMlNvcnJ5MTJAcHJv\ndG9uLm1lPsLAEAQTFgoAgwWCZ89rtwMLCQcJkFo1FQkkXCWvRRQAAAAAABwA\nIHNhbHRAbm90YXRpb25zLm9wZW5wZ3Bqcy5vcmdC5xk40SnID8wdIwNWgnre\nyyC+Aj2AxFHaLX9jJiuRIAMVCggEFgACAQIZAQKbAwIeARYhBPQHp7e97V/3\njyv2Blo1FQkkXCWvAACXcQD3ZzVeJrz4fhNZoR+OOQi21fnVguEsSyGr9hrp\nLVUjzAD/dSpf7HdiX3O66htwTsIq0uMvyjl0LSofkwDZxquegATHiwRnz2u3\nEgorBgEEAZdVAQUBAQdAy17B2VMx5Z0EKQEKsFttbeiPAef9KLApupJ3LkKP\noQoDAQgH/gkDCPflLRUYua52YAAAAAAAAAAAAAAAAAAAAADk7ikFQRBKTG06\nuU+1cdcX3DnSwradV212xQsXQXNWcFAXeUqhAnhK4pkb6mHPPkPCoNVU1YDC\nvgQYFgoAcAWCZ89rtwmQWjUVCSRcJa9FFAAAAAAAHAAgc2FsdEBub3RhdGlv\nbnMub3BlbnBncGpzLm9yZ6CCImF5HFsNwJ3mtb1J2RTZ1UaSiLnn9UI5DYjB\nUffOApsMFiEE9Aent73tX/ePK/YGWjUVCSRcJa8AALNTAQCTMPsQpKr3u/lc\naAS6EhLRdcz9wA4OeULCpGoB+nW9owEAp7LkBeQk7yUxLgkGsZ5Tg+J/JLuf\nZcoSxIirk+cWkAk=\n=9yF/\n-----END PGP PRIVATE KEY BLOCK-----\n",
"SignedKeyList": {
"Data": "[{\"Primary\":1,\"Flags\":3,\"Fingerprint\":\"f407a7b7bded5ff78f2bf6065a351509245c25af\",\"SHA256Fingerprints\":[\"efd599ddf0982cd7985d64f1631a7ee69f8179a8d939bf1670af8793093022ef\",\"5e307363ba66e91614f46d47cac0dc119f458a6087b5120f9a03ce5ed0673355\"]}]",
"Signature": "-----BEGIN PGP SIGNATURE-----\n\nwsAvBAEWCgChBYJnz2vzCZBaNRUJJFwlrzMUgAAAAAARABljb250ZXh0QHBy\nb3Rvbi5jaGtleS10cmFuc3BhcmVuY3kua2V5LWxpc3RFFAAAAAAAHAAgc2Fs\ndEBub3RhdGlvbnMub3BlbnBncGpzLm9yZ5Z4W60qBWs8kT5mxQXSt225j78O\nIEhmfeV9igj392+wFiEE9Aent73tX/ePK/YGWjUVCSRcJa8AAOpIAQDKBV40\nn/J0INAcU2zCOWVkodTMMssNpcTwhkNfEaWYvAD8DDqr8CsGwzLHDb9q6M4e\nrWMBssw6jS8YdEsfL83djww=\n=Dj30\n-----END PGP SIGNATURE-----\n"
},
"Token": "-----BEGIN PGP MESSAGE-----\n\nwV4DPnF0MvWfe5ASAQdASwP8pylBVSGwc3xEdF5IF66cFYuaDB8mqMpt/dXx\nxBswKec4qRxsV2lvaq1se46SmdgrPY1QbVLDHNggzhu/K0GThE5QOapNfD8f\n5TgXvEMe0nEB6BjwqDjggM8XxbA84fc5zysLyhKrMLG7nxnda7RnCFMdJV7q\nxd1lOmKOq4wJQt74af1nT0SqOgKv5hcB6WoFdcs1aJcP6CjBGAKSoNCqFkGt\n8dWC7V9OHUHBXRT6XZSke5AqAG22eJN1nEPeif/E2g==\n=GD0C\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\n\nwrsEARYKAG0FgmfPa/MJkMLpLHsHaoW5RRQAAAAAABwAIHNhbHRAbm90YXRp\nb25zLm9wZW5wZ3Bqcy5vcmdRny+PQ37knmM2MIITJuqNNgr+FP/OJMmkzFm0\nESqz7hYhBBSVmkGHWXT8eoP7zcLpLHsHaoW5AAAtHAEAzQ49zS0Ck0H9SAff\ntplHPkc0iP1VtrBjo8w+mCHc9ioA/jhr2Hlk2cRMY0X6gncDJ2c4o4gdHhXp\nV5dXpRjKwAEI\n=oBdr\n-----END PGP SIGNATURE-----\n"
}
],
"Auth": {
"ModulusID": "fT-fHNQexHafNYev4Qz49aetYhhjFOJCD8E8GYYOMY6o0U9WwINhnI76D9k7f6WB8_GaMISfd3a_cxe6vEUGxw==",
"Version": 4,
"Salt": "b9Z9lFv8quYchg==",
"Verifier": "40dbIETbVNS/UGn2BtauTfq1We+zkA3YB3tAZ5sUTRBfoEc5QbMy9XNhFYQqT3EFlPzXEstENHLMDdNMFB7pn4H2yUICwefwHJUuB9MRfaz63qba3mu+KOqx9+Wi46+FE8hi/v2S8Oi9/+qpwwOsEumUtGNTSoO6QuxfHALf6B0GgyFUeJjV8yoJXt+TUoU7KXXk4EjUIXPEFMKXFA5ut+N8WfiWMiORLQwxFP1Q2tsVeNhQEWo2wFnduLs/mMnhYmi9HUn91FQJkZ6zCwSf9m5vipMaDqB9VIBY2dEdNpHtNEt6s7RhDALd6BP8IfDxatmcm7mgu8mMvLz/nau7kg=="
}
}
Of interest to us are the two PGP private keys in the payload:
PrimaryKey
, which we shall see is encrypted, and will
henceforth be called the user key (Proton's name for
it)AddressKeys.PrivateKey
, which we shall see is
encrypted, and will henceforth be called the address
key (Proton's name for it)The analysis of how this payload is generated and the verification that it is encrypted is a bit complex, and is collapsed in the details section, below. What follows is the short version.
The user key is a PGP curve25519 private key, which is encrypted with AES256 before being sent to the server (a bcrypt key-stretch of your password is used as the AES256 passphrase).
The address key is a different private key, AES256
encrypted with a token as the passphrase. This token is itself encrypted
when sent under AddressKey.Token
, using your user key.
The passphrase used to encrypt the user key is never sent to Proton,
only a verifier derived from it (under Auth.Verifier
). This
“verifier” is from SRP,
which Proton uses for authentication. It is a parameter of a
mathematical function, which, given an input value, returns if it
matches the password it was derived from. Crucially though, it does not
allow to derive back the password (it is a one-way cryptographic
function). This has similar properties as a public key for RSA
signing and verification.
What matters to us is, as we shall see, only the user key, which Proton only receives in AES256 encrypted form, can decrypt the address key, which in turn can decrypt our data.
Proton's security model contrasts with Mozilla's, which uses PBKDF2 to key-stretch
your password then HKDF
to derive from it an encryption key and authentication token
(authPW
).
Proton's client is open source, so looking through the code gives us hints of where to add breakpoints to verify the computed values before they are sent to the server.
Adding the breakpoints, upon clicking “Sing up” we stop in the handleSetupKeys function, before any requests are sent:
The first subroutine call, on line 23,
generateKeySaltAndPassphrase
, receives a
password
and returns passphrase
. Looking at
the implementation, this passphrase
is the bcrypt
key-stretch of your password.
The second subroutine call, on line 25,
getResetAddressKeysV2
, generates a PGP curve25519 private
key and encrypts it with AES256 with passphrase
as the key.
This is the user key, and is appropriately attributed to the
userKeyPayload
variable. Another return value of this
function is attributed to addressKeyPayloads
. We'll look
into it later.
First, we present the user key generation call stack:
After being generated, on 3., the user key is encrypted, the call stack being presented below:
passphrase
(bcrypt of your password) as
the encryption keyLooking back at what goes over the wire and at the original function
from the screenshot, PrimaryKey
receives
userKeyPayload
, which is your encrypted private key (user
key), as we've shown above.
Now let's analyse addressKeyPayloads
, the other return
value of getResetAddressesKeysV2
, which is attributed to
AddressKeys
in the payload sent over the wire.
What Proton calls the “address keys” are a set of private keys
different from your user key, one per address, generated on
getResetAddressesKeysV2
(here).
The generation code is partially reproduced below, for reference.
const { token, encryptedToken, signature } = await generateAddressKeyTokens(userKey);
const { privateKey, privateKeyArmored } = await generateAddressKey({
: Email,
email: token,
passphrase: v6Key ? KEYGEN_CONFIGS[KEYGEN_TYPES.PQC] : keyGenConfigForV4Keys,
keyGenConfig;
})
// ...
return {
,
newActiveKeys: {
addressKey,
privateKey: AddressID,
addressID,
}: {
addressKeyPayload,
AddressID: privateKeyArmored,
PrivateKey: signedKeyList,
SignedKeyList: encryptedToken,
Token: signature,
Signature,
},
onSKLPublishSuccess; }
Here, userKey
is your unencrypted private key. First, a
token
is generated and encrypted with your
userKey
.
Then, generateAddressKey
is used to generate a new
privateKey
. We reproduce it below, for reference.
export const generateAddressKey = async <C extends KeyGenConfig | KeyGenConfigV6>({
,
email= email,
name ,
passphrase= KEYGEN_CONFIGS[DEFAULT_KEYGEN_TYPE] as C,
keyGenConfig : GenerateAddressKeyArguments<C>) => {
}const privateKey = await CryptoProxy.generateKey<C['config']>({
: [{ name, email }],
userIDs...keyGenConfig,
;
})
const privateKeyArmored = await CryptoProxy.exportPrivateKey({ privateKey: privateKey, passphrase });
return { privateKey, privateKeyArmored };
; }
This uses the same functions used to generate the user key, generating an AES256 encrypted private key, with the aforementioned token as the passphrase.
Going back to getResetAddressKeysV2
, notice that in the
end, only the encrypted form of this token is sent over the wire.
This concludes our analysis of getResetAddressKeysV2
,
which, going back to the original function, returns the encrypted user
and address key payloads.
Finally, looking at the original function, for authentication
srpVerify
is called on line 45. This eventually triggers
the API call we started with. This function receives your raw password,
as shown in the screenshot, but it's only used to obtain the verifier,
which is the only information transmitted on the wire (in
Auth.Verifier
). The call stack for that is shown below.
credentials
is your raw passwordcredentials
and only uses it for
getRandomSrpVerifier
srpGetVerify
This concludes our verification - both PGP private keys that are present in the payload are encrypted, and the ultimate encryption passphrase (bcrypt key-stretch of your password) is not sent over the wire.
We can verify the algorithms we analysed in the code using gpg on the data:
$ gpg --list-packets <userKey.txt
...
:secret sub key packet:
...
iter+salt S2K, algo: 9, SHA1 protection, hash: 8, salt: A7C64D6DC3D6C8CC
$ gpg --list-packets <token.txt
...
:pubkey enc packet: version 3, algo 18, keyid 3E717432F59F7B90
$ gpg --list-packets <addressKey.txt
...
:secret sub key packet:
...
iter+salt S2K, algo: 9, SHA1 protection, hash: 8, salt: F7E52D1518B9AE76
$ gpg --list-packets <mail.txt
...
:pubkey enc packet: version 3, algo 18, keyid F8146568C6A6EF19
The user and address keys are encrypted key pairs, the sub key being the key used for encryption. S2K algo is string to key algorithm, and algo 9 is AES256.
The token and mail are encrypted messages, and algo 18 is ECDH.
Upon login, we see our browser performing the following requests to Proton:
The first POST requests perform SRP authentication, which we'll look at later.
Of immediate interest are requests 3. and 5., which fetch, respectively, our user and address keys. These are their respective responses:
{
"Code": 1000,
"User": {
...
"Keys": [
{
"ID": "rCyvTcFl0VzHC--LYWTWywcjozPp2aMynXf0I9OX-8WNnBwxYUn26w3ZffPvmTyHD-r5ZPMwx2hnYGy9Mys2Lg==",
"Version": 3,
"Primary": 1,
"RecoverySecret": null,
"RecoverySecretSignature": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\n\nxYYEZ89rthYJKwYBBAHaRw8BAQdA/Jc/va78lcj/8xcDfZzpdiRuWC4+rHrc\nZIEt9tKvbnD+CQMILb2ffbo5FrFgAAAAAAAAAAAAAAAAAAAAALDEUP0fxPJt\nP1EZxGWF8XTEmIkmhJgCiWN764/2Tf91HwflqZEq7zqJscsEs5u1vOamTRME\nms07bm90X2Zvcl9lbWFpbF91c2VAZG9tYWluLnRsZCA8bm90X2Zvcl9lbWFp\nbF91c2VAZG9tYWluLnRsZD7CwBEEExYKAIMFgmfPa7YDCwkHCZDC6Sx7B2qF\nuUUUAAAAAAAcACBzYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnQdJ6etFr\nE4AR1IEVHqNy68leL5otQZpxk2j5zEPTNYUDFQoIBBYAAgECGQECmwMCHgEW\nIQQUlZpBh1l0/HqD+83C6Sx7B2qFuQAALxsBAKr9tDOwpXLmXU9YwUIN2S5B\no+sylWFSA3E9p6Pfk5JbAQCY4JXAgoqqtmnpBADIQBhcqpjjgV2ZL3lVdj/y\nwndWCseLBGfPa7YSCisGAQQBl1UBBQEBB0B0nocmR5w6HS1GIBuhCmH2eFa/\n+hLRwkf5K+b/dnQ3RgMBCAf+CQMIp8ZNbcPWyMxgAAAAAAAAAAAAAAAAAAAA\nAEgqjPc3zuLe9wVsXVcdstRzoh08Bq1y8iQMXShGlk9FF3mi+IMB5T6GiN4J\nArj8P7VHrCeVbsK+BBgWCgBwBYJnz2u2CZDC6Sx7B2qFuUUUAAAAAAAcACBz\nYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnWBV9jAu/soaf/oQz+J05BR1g\npuIISpjc0TEhb7LAKd0CmwwWIQQUlZpBh1l0/HqD+83C6Sx7B2qFuQAAoDwA\n/jAVI8PirjGrw+2HGourdmXIJN2zZTlSqwoM/5+PBvHHAQDPCl2JoMNnNy59\n3Dn9hTM8njGbsoW8LRov1CO6OWBRCw==\n=fHDb\n-----END PGP PRIVATE KEY BLOCK-----\n",
"Fingerprint": "14959a41875974fc7a83fbcdc2e92c7b076a85b9",
"Active": 1
}
],
...
}
{
"Code": 1000,
"Addresses": [
{
...
"Keys": [
{
"ID": "geP5RVQ1t9QkqidKHpORMfyHGMEm5INCTUZrCrqC7KnoR79l4kuT-dibrHYhlkWKByAA1fplzrgqA_dYNllUxA==",
"Primary": 1,
"Flags": 3,
"Fingerprint": "f407a7b7bded5ff78f2bf6065a351509245c25af",
"Fingerprints": [
"25d0153caa16c99e1f1b141af8146568c6a6ef19",
"f407a7b7bded5ff78f2bf6065a351509245c25af"
],
"PublicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: ProtonMail\n\nxjMEZ89rtxYJKwYBBAHaRw8BAQdAuFPeIyO496A9ohEYaBnQcOOZs0/CyaXp\nuaqhRNoWwqXNMVRlc3QxMlNvcnJ5MTJAcHJvdG9uLm1lIDxUZXN0MTJTb3Jy\neTEyQHByb3Rvbi5tZT7CwBAEExYKAIMFgmfPa7cDCwkHCZBaNRUJJFwlr0UU\nAAAAAAAcACBzYWx0QG5vdGF0aW9ucy5vcGVucGdwanMub3JnQucZONEpyA/M\nHSMDVoJ63ssgvgI9gMRR2i1/YyYrkSADFQoIBBYAAgECGQECmwMCHgEWIQT0\nB6e3ve1f948r9gZaNRUJJFwlrwAAl3EA92c1Xia8+H4TWaEfjjkIttX51YLh\nLEshq/Ya6S1VI8wA/3UqX+x3Yl9zuuobcE7CKtLjL8o5dC0qH5MA2carnoAE\nwqgEEBYIAFoFAmfPa/QJENgGwa9ZeOjHFiEECoZS/l1TOGBXiZ/p2AbBr1l4\n6McsHG9wZW5wZ3AtY2FAcHJvdG9uLm1lIDxvcGVucGdwLWNhQHByb3Rvbi5t\nZT4FgwDtTgAAAG0MAP9EFBa+JQ6gfaofbq4Ce8LcUPZhGObjr/+A9qq7lsGv\nEAEA7Xaa33Lcd0pupwSuA9vp29NPXAe+Xt4fMKS1/fRYoA7OOARnz2u3Egor\nBgEEAZdVAQUBAQdAy17B2VMx5Z0EKQEKsFttbeiPAef9KLApupJ3LkKPoQoD\nAQgHwr4EGBYKAHAFgmfPa7cJkFo1FQkkXCWvRRQAAAAAABwAIHNhbHRAbm90\nYXRpb25zLm9wZW5wZ3Bqcy5vcmeggiJheRxbDcCd5rW9SdkU2dVGkoi55/VC\nOQ2IwVH3zgKbDBYhBPQHp7e97V/3jyv2Blo1FQkkXCWvAACzUwEAkzD7EKSq\n97v5XGgEuhIS0XXM/cAODnlCwqRqAfp1vaMBAKey5AXkJO8lMS4JBrGeU4Pi\nfyS7n2XKEsSIq5PnFpAJ\n=lKA4\n-----END PGP PUBLIC KEY BLOCK-----\n",
"Active": 1,
"Version": 3,
"Activation": null,
"PrivateKey": "-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\n\nxYYEZ89rtxYJKwYBBAHaRw8BAQdAuFPeIyO496A9ohEYaBnQcOOZs0/CyaXp\nuaqhRNoWwqX+CQMIwWmFqunn+OVgAAAAAAAAAAAAAAAAAAAAALEooTUmAzyJ\no5G+jO8ci3ywMAl2IBGWtY04qETR5BCJJVTIA8OBZ6BKP3TgmKimuxf/FNzI\nqs0xVGVzdDEyU29ycnkxMkBwcm90b24ubWUgPFRlc3QxMlNvcnJ5MTJAcHJv\ndG9uLm1lPsLAEAQTFgoAgwWCZ89rtwMLCQcJkFo1FQkkXCWvRRQAAAAAABwA\nIHNhbHRAbm90YXRpb25zLm9wZW5wZ3Bqcy5vcmdC5xk40SnID8wdIwNWgnre\nyyC+Aj2AxFHaLX9jJiuRIAMVCggEFgACAQIZAQKbAwIeARYhBPQHp7e97V/3\njyv2Blo1FQkkXCWvAACXcQD3ZzVeJrz4fhNZoR+OOQi21fnVguEsSyGr9hrp\nLVUjzAD/dSpf7HdiX3O66htwTsIq0uMvyjl0LSofkwDZxquegATHiwRnz2u3\nEgorBgEEAZdVAQUBAQdAy17B2VMx5Z0EKQEKsFttbeiPAef9KLApupJ3LkKP\noQoDAQgH/gkDCPflLRUYua52YAAAAAAAAAAAAAAAAAAAAADk7ikFQRBKTG06\nuU+1cdcX3DnSwradV212xQsXQXNWcFAXeUqhAnhK4pkb6mHPPkPCoNVU1YDC\nvgQYFgoAcAWCZ89rtwmQWjUVCSRcJa9FFAAAAAAAHAAgc2FsdEBub3RhdGlv\nbnMub3BlbnBncGpzLm9yZ6CCImF5HFsNwJ3mtb1J2RTZ1UaSiLnn9UI5DYjB\nUffOApsMFiEE9Aent73tX/ePK/YGWjUVCSRcJa8AALNTAQCTMPsQpKr3u/lc\naAS6EhLRdcz9wA4OeULCpGoB+nW9owEAp7LkBeQk7yUxLgkGsZ5Tg+J/JLuf\nZcoSxIirk+cWkAk=\n=9yF/\n-----END PGP PRIVATE KEY BLOCK-----\n",
"Token": "-----BEGIN PGP MESSAGE-----\nVersion: ProtonMail\n\nwV4DPnF0MvWfe5ASAQdASwP8pylBVSGwc3xEdF5IF66cFYuaDB8mqMpt/dXx\nxBswKec4qRxsV2lvaq1se46SmdgrPY1QbVLDHNggzhu/K0GThE5QOapNfD8f\n5TgXvEMe0nEB6BjwqDjggM8XxbA84fc5zysLyhKrMLG7nxnda7RnCFMdJV7q\nxd1lOmKOq4wJQt74af1nT0SqOgKv5hcB6WoFdcs1aJcP6CjBGAKSoNCqFkGt\n8dWC7V9OHUHBXRT6XZSke5AqAG22eJN1nEPeif/E2g==\n=GD0C\n-----END PGP MESSAGE-----\n",
"Signature": "-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\n\nwrsEARYKAG0FgmfPa/MJkMLpLHsHaoW5RRQAAAAAABwAIHNhbHRAbm90YXRp\nb25zLm9wZW5wZ3Bqcy5vcmdRny+PQ37knmM2MIITJuqNNgr+FP/OJMmkzFm0\nESqz7hYhBBSVmkGHWXT8eoP7zcLpLHsHaoW5AAAtHAEAzQ49zS0Ck0H9SAff\ntplHPkc0iP1VtrBjo8w+mCHc9ioA/jhr2Hlk2cRMY0X6gncDJ2c4o4gdHhXp\nV5dXpRjKwAEI\n=oBdr\n-----END PGP SIGNATURE-----\n"
}
],
"SignedKeyList": {
...
}
}
],
"Total": 1,
"SignedAddressList": []
}
Immediately we notice that User.Keys.PrivateKey
is our
encrypted user key sent during sign-up (check the payload at the start
of this article), and that Addresses[].Keys.PrivateKey
is
the encrypted address key we sent during sign-up. Finally,
Token
is the encrypted token we need, alongside our
decrypted user key (for which we need our password), to decrypt our
address key, which we need to decrypt our mail!
The details on how these keys are fetched and decrypted can be observed by adding breakpoints to the client code, an analysis being presented in the collapsed details section, below. The short version is, the encrypted keys fetched from Proton are decrypted with our password, which comes from us filling the login form, and is never sent to Proton. The keys and password are kept in a secure local session storage, for later usage to decrypt mail locally on our computer.
The entry point for the login flow is React rendering
LoginForm
, with handleNextLogin
called as part
of onSubmit
, here,
partially reproduced below.
<LoginForm
...
onSubmit={async (data) => {
try {
const validateFlow = createFlow();
const result = await handleNextLogin({
...
username: data.username,
password: data.password,
...
});
if (validateFlow()) {
return await handleResult(result);
}
} catch (e) {
handleError(e);
handleCancel();
}
}}
handleNextLogin
then calls
next
, which calls
syncUser
syncUser
fetches the user key from the aforementioned
endpoint, heresyncAddresses
fetches the address key from the
aforementioned endpoint: 1,
2Finally, going back to the onSubmit
code reproduced
above, handleResult
is called with the result of fetching
our user and address keys. This, in turn, stores our keys and password
in a secure local session storage. The call stack analysis for that is
presented below.
handleResult
calls
onLogin
, defined here
to be handleLogin
, which calls
handleLoginResult
, which in turn calls
handleRedirectLogin
, which as the name suggests redirects
(to the inbox page) after successful login, but not before saving
our keys and password to a secure local session store, which was
created by bootstrapApp
, see 1,
2,
3,
4,
5,
finally lending at setPassword
in such store.
Going back to the remaining requests (1., 2., 4., 6.), we verify that our browser does not send our password, only the SRP details:
The code that generates them together with comments detailing the
math behind it can be found here
and here.
As can be seen there, getSrp
only uses your password to
derive a bcrypt passphrase, using the same functions we already analysed
in sign-up details section above.
After the passphrase is obtained, only it is used to derive the SRP values sent to Proton, your password only being used for the bcrypt derivation and not sent to Proton.
To decrypt our mail the required information is, as discussed above, our address key, for which our user key is needed, for which, ultimately, our password is needed, which Proton should not have. Let's see how that works.
When refreshing your inbox, a POST is made to
/api/core/v5/events/<id>
to get new messages, then, a
new POST is made to
/api/mail/v4/conversations/<msg-id>
to retrieve the
message contents, including the encrypted PGP body.
Having retrieved the encrypted email body, how is it decrypted?
By adding breakpoints to the code, we land here right before the message is decrypted and shown to us in the browser, the code being partially reproduced below.
const messageKeys = await getMessageKeys(message.data);
= await decryptMessage(getData(), messageKeys.privateKeys, onUpdateAttachment); decryption
The code that gets the message keys used for decryption has the following call stack:
Let's pause and look at this last function:
const [user, userKeys, addresses] = await Promise.all([
dispatch(userThunk()),
dispatch(userKeysThunk()),
dispatch(addressesThunk()),
;
])
...
const keys = await getDecryptedAddressKeysHelper(
.Keys,
address,
user,
userKeys.authentication.getPassword()
extraArgument; )
As we theorized, this gets the address and user keys, and decrypts them using our password.
So the question is, how is the password obtained? We can follow through the code to see it is loaded from a local secure session store - see 1, 2, 3, 4, 5, 6, 7.
If you follow through userKeysThunk
instead of
extraArgument.authentication.getPassword
([1], above),
you'll see that the user keys thunk eventually calls the latter, so the
analysis is the same.
Then the question becomes, how is the password saved into this local store? We already know that!
As we've shown in the details of the login section above, it is saved in the local store from our input to the login form, during which the password is never sent to Proton, thus concluding our journey of showing how Proton is end-to-end encrypted.
Let's verify the decryption flow we came up with by writing a client using only OpenPGP-js and bcrypt-js libraries.
This client will receive only encrypted information sent to Proton - encrypted private and address keys and token - and our unencrypted password, which is not sent to Proton, and it will decrypt PGP mail read from Proton.
The full client code is available here (just 47 SLOC). The important bits are shown below.
async function decryptMessage(
: string,
armoredMessage: string,
armoredKey: string
passphrase
) {const privateKey = await openpgp.decryptKey({
: await openpgp.readPrivateKey({armoredKey}),
privateKey
passphrase;
})const message = await openpgp.readMessage({
armoredMessage;
})const {data: decrypted} = await openpgp.decrypt({
,
message: privateKey
decryptionKeys;
})return decrypted;
}
async function derivePassphrase(password: string, salt: string) {
const saltBinary = binaryStringToArray(atob(salt));
const hash: string = await bcrypt.hash(password, BCRYPT_PREFIX + bcrypt.encodeBase64(saltBinary, 16));
return hash.slice(29);
}
async function main() {
// Derive passphrase from password with bcrypt
const passphrase = await derivePassphrase(password, salt);
// Decrypt the token with the private key (and the private key with the passphrase)
const decryptedToken = await decryptMessage(tokenArmored, userPrivateKeyArmored, passphrase);
// Decrypt the message with the address key (and the address key with the token)
const decrypted = await decryptMessage(pgpArmoredMessage, addressKeyArmored, decryptedToken);
return decrypted;
; }
If we supply the following PGP message to it:
-----BEGIN PGP MESSAGE-----
Version: ProtonMail
wV4D+BRlaMam7xkSAQdAnFdtbEFC5h3q5/zXOhIuTLwVc7/mNQj/JaJHu7QY
G2MwjRVaJDCMdiAWln46o5ZNyuXeJbN8lcfIcxhjaR7LIKFz7kPxww1TAi60
El5d4Qfm0sBwAX+qfJOtZmSmZuuwey33qrKm8DuTnK6TtjuYsx+gVPV+BWJQ
EPJBR5nycMnipNhguaJM4jnoQQMOA7cTYPcI7pHnhepcOYJ06+77xX+BbZEx
gXweT1kF2Uxzqux6GHTIXUsI1YAp1gJZ3rl3PDAkYIG/eOFcCYLsrDQVl7Ye
NQTBXmdCSAD/zFOVRjDcg0qq8xKW7puRBFmUUK8nti6Kr4WUNoFELzNQ4rUd
UFlwzYcTbla4eDGKrlWS93oTL3JoQR8E6ropTrtp5sRcrvNqJugxLMJELMLg
OeqsE3myptH6BJsyD0n2ft5paNCMNzk7Q4m/dd7MW8HwcbYK72aIRVl2M7VU
HG8vdUzi9GtlGjDz+ASNNhmvl1ibhC0uE15gr0rIZERf10gYlZYKeY+fAQ==
=h0jo
-----END PGP MESSAGE-----
As well as the values from the start of this article, we get:
$ node main.js 'SignUpTest123!' 'oU4K+rJCCoNeGBsxKkVkmA==' "$(cat pgp.txt)" "$(cat userKey.txt)" "$(cat addressKey.txt)" "$(cat token.txt)" 2>/dev/null
Decrypted message:
Secret text!
Sent with Proton Mail secure email.
Which concludes our investigation into Proton's E2E encryption.
Proton's E2E encryption relies on AES256 for the encryption of your private key and curve25519 for encryption of your data, both of which reside on their servers in such encrypted form.
This exercise, a follow up from the Mozilla one, pushed me to learn more about cryptography and Proton's protocols and clients, which are sparsely documented (less so now, I hope!).
This analysis is limited to Proton Mail, a similar venture with their other services such as Drive will have to wait for a follow-up post.
I'm a happy customer for several years now, using Proton to store my personal data, which involves trust, but verifying their claims has made me appreciate their services even more.
See also this 2017 presentation by Proton's CTO for more details on their security model.
A question arose on Reddit: when you send mail to non-Proton users, it can't possibly be E2E encrypted. How does that work?
When sending mail, we see on devtools the PGP encrypted mail body being sent to Proton, presumably for storage in such encrypted form.
As we analysed, Proton cannot decrypt this, thus cannot send it to non-Proton users.
We also see a different POST request, though, with a form to
/api/mail/v4/messages/<messageId>
:
This form contains an encrypted email body in
Packages[text/plain][Body]
, and an unencrypted session key
in Packages[text/plain][BodyKey][Key]
.
This key is generated for sending just this message - there is no connection with your user/address keys that can decrypt your other messages. You can verify it's a different key on every email.
Relevant code:
The last function calls CryptoProxy.encryptMessage
which
we're already familiar with, using the sessionKey
generated
during that call stack to encrypt the message.
We update the POC client to print the raw mail body using only the information contained within that POST request (sent to Proton):
$ node plain.js 'MjEwLDE5Miw3MiwxLDIzNCwyNDMsMTUwLDk2LDIxNCwyMjEsMTQ5LDE0NSwxNDcsNjYsMTU2LDIyMCw1Niw3MCwyMjksMTc4LDI1LDE5OCwyMjIsMTI2LDU2LDEwNiwyMDAsMTE3LDE4NywxMzQsMjksNTAsMjMxLDIzNiwyNiwyNDMsMjgsMTEsOTQsMTc0LDQ4LDI3LDksMjEyLDIyMCw1LDM4LDMyLDIwNCwxNTMsMiwyMDIsOCwxNyw4MywxMDIsMywyMzcsMTkxLDEyMywyMjQsOTQsNDMsODQsMTMsMjUsMjQ1LDY5LDIxNCwyMDksMTUyLDkxLDE1MSwxMDgsMTc3LDExOCwyNDQsMjMwLDg5LDIwMywxNzcsMjA2LDU1LDEzNiwxMzMsMTM3LDE0NCwzNywzNSwxNzksMTk4LDkxLDg3LDI0MywyMjAsNCw4NywxMjEsNTIsMzUsMTIyLDE3MSwyMjgsNzksNjAsMTIxLDIzNiwyNTUsMTAsMTc3LDcwLDE1MCw5OSw1NCwxOTcsODgsNjcsMTY2LDU5LDE2MywyNDYsMjE3LDI0NCw0NCwxNjAsMjAzLDI1LDc3LDI0MSwxOTIsMjQ4LDIyMyw0OCwxMTcsNTAsMTgzLDExMywxNjMsOCwxODIsNjMsMjgsMjI3LDEzMSw0MiwxNDksMjgsNzksNDcsMTMxLDIyNyw3MSwxMTAsMTAxLDExNSwxNjcsMTE2LDU0LDExNywxNiwxNjMsMTQ3LDE0OSwyMDcsMTc4LDU3LDEwMywxODYsNTYsMjM2LDE2MCwxMCwyMDEsMzMsMTIsMTIwLDEsMjI4LDY0LDI5LDI1MywxODMsMTEzLDQ5LDEzNCwxMSwxNTQsNjYsMTc3LDE3NCwxMDksMTE2LDIyNCwyMjMsMzAsMTU2LDE2MSwxOTgsMTE0LDIxMywyMzYsNTksMjQzLDIwMiw4Myw0MSw5MywxOTEsMzcsMTcsMjExLDEyNyw0NywyNCwyMDQsMjMxLDI4LDI0OSw1MSw3MCwxNTAsMTgwLDc0LDEzNiwxNywxODgsMTI3LDExMCwyMDgsMTM2LDE4NiwxODcsMTk2LDIyMCwyOSwxNzYsMjAsMTA5LDE0MywxNzcsMSwxNjAsMjIyLDIzLDk2LDE0OSwxNDMsMjQ1LDcsMjI4LDI4LDIwMywyNiwyMDUsMzIsMjExLDI1NCwyOCwxNDEsMjE2LDE3NywxNzYsMjA3LDE4NiwzOCwxMzIsNDk=' 't+F6T81947YFCwL073uGWdMuLfpuw+Vbrt9m/Lckgm8='
secure-body
Which shows that sending email to non-Proton users is not end-to-end encrypted, as documented by Proton (when not using the password protected email feature).
Due to encoding messiness I couldn't properly copy the binary blob from devtools, so I just added a breakpoint here and transformed the Uint8Array to base64, the argument to the POC client.
The difference when sending mail to other Proton users is, the mail is not encrypted with a session key, but with the public key of the recipient.
We can verify that on the call stack above, and that no session key is present in the form.
So, as documented by Proton, sending mail to other Proton users is end-to-end encrypted.
Public domain. Originally published on 23 March 2025.