Home | rss
index

Proton

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.

Sign up

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:

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.

Encryption flow

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:

Breakpoint in developer tools showing part of the call stack

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:

  1. getResetAddressKeysV2 Several key generation and derivation
  2. generateUserKeys First key generation, the PGP curve25519 private key
  3. generateAddressKey
  4. generateKey
  5. pmcrypto/generateKey
  6. openpgpjs/generateKey

After being generated, on 3., the user key is encrypted, the call stack being presented below:

  1. generateAddressKey (continuing from 3., above)
  2. exportPrivateKey
  3. pmcrypto/encryptKey
  4. openpgpjs/encryptKey
  5. openpgpjs/encrypt uses AES256 with passphrase (bcrypt of your password) as the encryption key

Looking 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,
    passphrase: token,
    keyGenConfig: v6Key ? KEYGEN_CONFIGS[KEYGEN_TYPES.PQC] : keyGenConfigForV4Keys,
});

// ...

return {
    newActiveKeys,
    addressKey: {
        privateKey,
        addressID: AddressID,
    },
    addressKeyPayload: {
        AddressID,
        PrivateKey: privateKeyArmored,
        SignedKeyList: signedKeyList,
        Token: encryptedToken,
        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,
    name = email,
    passphrase,
    keyGenConfig = KEYGEN_CONFIGS[DEFAULT_KEYGEN_TYPE] as C,
}: GenerateAddressKeyArguments<C>) => {
    const privateKey = await CryptoProxy.generateKey<C['config']>({
        userIDs: [{ name, email }],
        ...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.

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.

Login

Upon login, we see our browser performing the following requests to Proton:

  1. POST /api/core/v4/auth/info
  2. POST /api/core/v4/auth
  3. GET /api/core/v4/users
  4. GET /api/core/v4/keys/salts
  5. GET /api/core/v4/addresses
  6. PUT /api/auth/v4/sessions/local/key

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();
      }
  }}

Finally, 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:

SRP login

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.

Mail decryption

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.

Response from /api/mail/v4/conversations/<msg-id> viewed on devtools

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);

decryption = await decryptMessage(getData(), messageKeys.privateKeys, onUpdateAttachment);

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(
    address.Keys,
    user,
    userKeys,
    extraArgument.authentication.getPassword()
);

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.

Proof of concept

Decryption flow

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(
  armoredMessage: string,
  armoredKey: string,
  passphrase: string
) {
  const privateKey = await openpgp.decryptKey({
    privateKey: await openpgp.readPrivateKey({armoredKey}),
    passphrase
  });
  const message = await openpgp.readMessage({
    armoredMessage
  });
  const {data: decrypted} = await openpgp.decrypt({
    message,
    decryptionKeys: privateKey
  });
  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.

Thoughts

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.

PS: Sending mail

A question arose on Reddit: when you send mail to non-Proton users, it can't possibly be E2E encrypted. How does that work?

Non-Proton users

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>:

Sending unencrypted email

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.

Proton users

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.

Sending encrypted email

So, as documented by Proton, sending mail to other Proton users is end-to-end encrypted.

Public domain. Originally published on 23 March 2025.

Home / Contact Info | rss