ProtonMail : forensic decryption of iOS App

Matthieu Regnery
8 min readAug 2, 2021

ProtonMail is a full PGP end-to-end encrypted email provider who is claiming privacy, anonymity and security. As forensic examiners, we need to extract data, especially encrypted ones, to help discover the truth. Commercial or other open source tools such as Cellebrite or Axiom are currently not recovering data from ProtonMail. So let’s dive into its iOS mobile app we recently had to process in a drug smuggling context.

TL;DR: ProtonMail local storage is as good as the device protection and the user additional protections optionally enforced. Full filesystem and Keychain dump are mandatory to decrypt messages and thus a checkm8 vulnerable device or access to specialized tools such as Cellebrite or GrayKey. We provide a Python Notebook containing all the code to extract, decrypt and export ProtonMail messages.

Security overview

ProtonMail claims to encrypt all emails with asymmetric open source PGP scheme. This is true for emails exchanged with other ProtonMail accounts and can be configured to work with other PGP friendly clients but is usually not the case for inbound or outbound messages exchanged with other providers. However, as we will confirm later, emails are stored locally PGP encrypted. That is an issue when trying to recover data on a device, hence the need of diving into the app.

What is really a time saver is that ProtonMail is open source. The iOS application code is available on GitHub along with documentation and disclaimers.

Also, ProtonMail is able to show messages in offline mode, therefore all data and decryption material is stored locally. We can only succeed in our quest.

ProtonMail iOS local storage security

We are looking at iOS application in order to recover and decrypt locally stored emails. In the documentation we learn that ProtonMail use “Appkey protection system” to

increase our chances to protect the data when iOS sandbox is compromised or when rogue application managed to dump keychain

Well that is exactly what we are trying to do… This protection comes with 3 levels that are defined by the user in the app .

  1. Biometric : SecureEnclave handles the protection of the AppKey. That means we have to ask the device to decrypt the AppKey in order to use it. As device passcode has to be known in order to decrypt data, this does not enhance security in our forensics scenario.
  2. PIN code : AppKey is encrypted with a derivation of this PIN. Even if the derivation is quite slow, we have a limited number of combination and bruteforcing it is a matter of time and complexity. Strong PIN can level up time needed to access data.
  3. No protection :-D

Local data trust model documentation explains further the threats and limitations of the implementation.

Some of these pieces of data is kept in Keychain, some in our local CoreData database, and some in UserDefaults dictionary inside the application directory

Well let’s see what is needed to decrypt emails and how to achieve that.

Nota: AppKey is also called mainKey in source code.

Locating the data

Extracting from a test device

We took an iPhone 7 and updated it to iOS 14.6 and installed last ProtonMail version available (1.15.3) at the time of research. For this first stage, we do not set additional PIN or biometric protection in the app (ie. default behaviour)

To extract data on this checkm8 vulnerable device, we use Checkra1n to :

  1. Make a full file system extraction of our test phone
  2. Extract and decrypt the keychain items with our tool

Keychain

In the Keychain, we find keys under the 2SB5Z68H26.ch.protonmail.protonmail access group (According to source code, this group would be 6UN54H93QT.ch.protonmail.protonmail for enterprise version):

  • pushNotificationSubscription
  • pushNotificationEncryptionKit
  • pushNotificationOutdatedSubscriptions
  • NoneProtection
  • disconnectedUsers
  • UsersManager.AtLeastoneLoggedIn

NoneProtection item would be the one to match AppKey definition. Its value is a 32 bytes binary blob looking random:

f77300d5d9b5fd5dccd1cf6bd722a4b740115b7c9e02efbe43c7bc7992d3fcfc

That’s all for Keychain right now, let’s see what we have in the filesystem.

Filesystem

ProtonMail application data are located in two paths as for many applications:

  • /private/var/mobile/Containers/Data/Application/
  • /private/var/mobile/Containers/Shared/AppGroup/

After a quick exploration, two files are required for email decryption (not talking about attachments):

  • /private/var/mobile/Containers/Shared/AppGroup/Library/group.ch.protonmail.protonmail.plist
  • /private/var/mobile/Containers/Shared/AppGroup/ProtonMail.sqlite

Analysing databases and plist useful for email recovery

ProtonMail.sqlite

This file is the main database containing messages. In particular, table ZMESSAGE contains the following columns:

‘Z_PK’, 
‘Z_ENT’,
‘Z_OPT’,
‘ZACTION’,
‘ZEXPIRATIONOFFSET’,
‘ZFLAGS’,
‘ZISDETAILDOWNLOADED’,
‘ZISENCRYPTED’,
‘ZISSENDING’,
‘ZMESSAGESTATUS’,
‘ZMESSAGETYPE’,
‘ZNUMATTACHMENTS’,
‘ZSIZE’,
‘ZSPAMSCORE’,
‘ZUNREAD’,
‘ZEXPIRATIONTIME’,
‘ZLASTMODIFIED’,
‘ZORGINALTIME’,
‘ZTIME’,
‘ZADDRESSID’,
‘ZBODY’,
‘ZMESSAGEID’,
‘ZMIMETYPE’,
‘ZORGINALMESSAGEID’,
‘ZPASSWORDENCRYPTEDBODY’,
‘ZPASSWORDHINT’,
‘ZUSERID’,
‘ZBCCLIST’,
‘ZCCLIST’,
‘ZHEADER’,
‘ZPASSWORD’,
‘ZREPLYTOS’,
‘ZSENDER’,
‘ZTITLE’,
‘ZTOLIST’
Snippet of table ZMESSAGE

Metadata seems encrypted and message itself is stored as an ascii armored PGP message.

We will take our test message displayed in the picture above as an example. The ZBODY value is :

-----BEGIN PGP MESSAGE-----
Version: ProtonMail

wcBMA3tomaHyc4NHAQf+Jp7Zn9bu1ubemE7jrBglgYrhewf5+MDc7OrO2PDG
UtpCm+yTb+ms2hpys+nF7LPFUMyGig5mMEChrBL+9rRtMFm6r5HD4lNQzfAc
[•••]1K/STBobEk7f0lNzlRrkWY0S+3uuxfuR5DLd5LM/ElcA/3R+vgUHrIyrwS8e
CE3+dMQAwgtv/ov/gK6IBu46YUPKKTUy
=IO/K
-----END PGP MESSAGE-----

A PGP message is encrypted with a symmetric key, itself encrypted with the public key of sender and correspondants. We need therefore to find the private key of the user and the passphrase to decrypt it.

Other values such as ZSENDER or ZTOLIST are encrypted with mainKey.

group.ch.protonmail.protonmail.plist

(or group.com.protonmail.protonmail.plist for enterprise version)

This file contains the following interesting properties :

  • authKeychainStoreKeyProtectedWithMainKey
  • usersInfoKeyProtectedWithMainKey

As stated in the name, values are encrypted with mainKey. Algorithm used is an AES CTR provided by the Go crypto library (source). As we love python, here is the code translated:

Once decrypted and deserialized, usersInfoKeyProtectedWithMainKey property contains:

{'root': {'NS.objects': [{'language': 'en_US',
'displayName': '',
'$class': {'$classname': 'UserInfo', '$classes': ['UserInfo', 'NSObject']},
'currency': 'EUR',
'showImages': 2,
'maxUpload': 26214400,
'attachPublicKey': 0,
'enableFolderColor': 0,
'signature': '',
'swipeLeft': 3,
'usedSpace': 644586,
'linkConfirmation': 'confirmationAlert',
'sign': 0,
'userKeys': {'NS.objects': [{'keyID': 'zbte5ZzpdD0J5tOc9VPx20TP4gp38gdqJchVio09BXg8lRWMBvYyRi-_H4JpCa_U-c4HKIEPNYJvtqrH-2HoRg==',
'$class': {'$classname': 'Key', '$classes': ['Key', 'NSObject']},
'fingerprintKey': '',
'Key.Signature': '$null',
'Key.Activation': '$null',
'privateKey': '-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\n\nxcMGBGDVwsgBCAC/QoTE2iC2mZPhD8IjgJagH1CU+nAjk/taPQPSNIWIYAM2\nSap5MXCelL+Y6byfQ3yVkNsG3UfFn2WSXSfBymiyAGXnsOgjoF3RkZq4lcw4\nRpfrRRvWNL3ZFNMca532u9UJLhoufnYZZtZGVdMcAbby7aJxnHkFawyXpbBB\nPVEI
[•••]
6N5KLvfwB2y39e0LJzhmKg\nE/vVJQFUJ4UXjtckn4v6c+PnT/blp0WyTZ3eXLbHUZQhCWBSnKGmcmAGtB6Z\nmc1O3ZEoJzMD1mj5ElVLu88Gtr3/vuh79D8ZfO6ttopqqapvoma2e9TatROI\nPQfL3oeK1/jmD+b3SJaCkYez6dpdW1Ttln5xNh9qX2A4\n=X8+h\n-----END PGP PRIVATE KEY BLOCK-----\n',
'Key.Token': '$null'}],
'$class': {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}},
'delinquent': 0,
'inheritParentFolderColor': 1,
'subscribed': 0,
'swipeRight': 0,
'notify': 1,
'maxSpace': 524288000,
'role': 0,
'passwordMode': 1,
'userAddresses': {'NS.objects': [{'displayName': '_azDm2-_Ia_aAUxSMG0f6mTjD2tRJrcPvoflASrNCxYY8JQhpMQG1ITDfhyJEcbt0ojboZLlqwSQ_BwIxiYT9w==',
'maxSpace': 'xperylabtest1@protonmail.com',
'$class': {'$classname': 'Address',
'$classes': ['Address', 'NSObject']},
'userKeys': {'NS.objects': [{'keyID': 'MLvTgP1ABMmxjbj-XFmTEBomVoD2ziebkVbQx0FauE--o6PWSzn3NtWsYvY5qjq6gE-uLIczUNtSLswtvj1wzg==',
'$class': {'$classname': 'Key', '$classes': ['Key', 'NSObject']},
'fingerprintKey': '',
'Key.Signature': '$null',
'Key.Activation': '$null',
'privateKey': '-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail\n\nxcMGBGDVwsgBCAC/QoTE2iC2mZPhD8IjgJagH1CU+nAjk/taPQPSNIWIYAM2\nSap5MXCelL+Y6byfQ3yVkNsG3UfFn2WSXSfBymiyAGXnsOgjoF3RkZq4lcw4[...]
6N5KLvfwB2y39e0LJzhmKg\nE/vVJQFUJ4UXjtckn4v6c+PnT/blp0WyTZ3eXLbHUZQhCWBSnKGmcmAGtB6Z\nmc1O3ZEoJzMD1mj5ElVLu88Gtr3/vuh79D8ZfO6ttopqqapvoma2e9TatROI\nPQfL3oeK1/jmD+b3SJaCkYez6dpdW1Ttln5xNh9qX2A4\n=X8+h\n-----END PGP PRIVATE KEY BLOCK-----\n',
'Key.Token': '$null'}],
'$class': {'$classname': 'NSArray',
'$classes': ['NSArray', 'NSObject']}},
'addressStatus': 1,
'signature': 'XperyLab Test1',
'addressSendStatus': 1,
'usedSpace': '',
'notificationEmail': 1,
'privateKey': 1,
'addressType': 1}],
'$class': {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}},
'notificationEmail': '',
'credit': 0,
'2faStatus': 0,
'autoSaveContact': 1,
'userId': 'rET5Z76A8F_I2T0tU6V3Etyf5EaJyFf3UHC1uhI6F8FuvY5WrD_f-kBCeYP9ZnkXGqziUw8d0ffIJgmRwpGVJg=='}],
'$class': {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}}}

and authKeychainStoreKeyProtectedWithMainKey:

{'root': {'NS.objects': [{'expirationCoderKey': {'NS.time': 647178656.050302,
'$class': {'$classname': 'NSDate', '$classes': ['NSDate', 'NSObject']}},
'$class': {'$classname': 'PMCommon.AuthCredential',
'$classes': ['PMCommon.AuthCredential', 'NSObject']},
'privateKeyCoderKey': '-----BEGIN PGP PRIVATE KEY BLOCK-----\nVersion: ProtonMail v1.1.0\nComment: https://protonmail.com\n\nxcMGBGDVwsgBCAC/QoTE2iC2mZPhD8IjgJagH1CU+nAjk/taPQPSNIWIYAM2Sap5\nMXCelL+Y6byfQ3yVkNsG3UfFn2WSXSfBymiyAGXnsOgjoF3RkZq4lcw4RpfrRRvW\nNL3ZFNMca532u9UJLhoufnYZZtZGVdMcAbby7aJxnHkFaw[...]
bHUZQhCWBSnKGmcmAGtB6Zmc1O3ZEoJzMD1mj5ElVLu88Gtr3/vuh7\n9D8ZfO6ttopqqapvoma2e9TatROIPQfL3oeK1/jmD+b3SJaCkYez6dpdW1Ttln5x\nNh9qX2A4\n=X8+h\n-----END PGP PRIVATE KEY BLOCK-----\n',
'userIDCoderKey': '5036689fc239a66e459e1536af562b6fde69a6d1',
'refreshTokenCoderKey': '76204759abd054bff1c6bbfc04bb8686489ed394',
'accessTokenCoderKey': 'a3d14e3a354d3f799aa8766168f1bce7be2dfc66',
'passwordKeySalt': 'L2HA+XWUEUQjofy4pudsFw==',
'AuthCredential.Password': 'YRkXs1C3ch58PEs35Jf4aljcp.G.T/S'}],
'$class': {'$classname': 'NSArray', '$classes': ['NSArray', 'NSObject']}}}

In our sample data, only one user is configured: xperylabtest1@protonmail.com. The three PGP PRIVATE KEY BLOCK are the same.

Let’s recover plain text message from the ZMESSAGE table. ZBODY is a PGP MESSAGE. We need to decrypt the private key “privateKeyCoderKey” with the passphrase “AuthCredential.Password” and then decrypt the message:

Metadata such as ZSENDER, ZTITLE or ZTOLIST are encrypted with mainKey. Using the decryptWithMainKey method will recover the plain text.

So we are able to decrypt ProtonMail messages from a forensic full filesystem dump and keychain dump on iOS !

Now let’s try to level up by setting a PIN protection in the app.

PIN Protection of mainKey

Let’s try to set a ProtonMail PIN code. In the app we go to Settings -> PIN -> Activate PIN Code -> Use PIN code

Appl lock screen when PIN is set

Then we dump again the filesystem as well as the keychain.

Source code explains the algorithms used to derive the PIN into an intermediate key: iKey = scrypt(pin, salt, 32768, 8, 1, 32). Then mainKey is obtained by decrypting the value stored in keychain with this key using AES-CTR. Let’s do that on our example. Keychain contains now:

'PinProtection.salt': b'U\x07\x94\xd5\x00n\x98{',
'PinProtection.version': b'1',
'PinProtection': b"l\x06v&\xa5\xfe\xecra\xac\xf7\x07\xa5 \x10\xf9\xc7\xa2\xff\xd7{\xa1\xe2\xe6\x92TC\xcc/\x82R\xd5\x86>>KT\xd9\xbe\xa8\xc0\xa1\x9c\xf7\x02C)\xe9\xfa\x85Y\x9d:aO\xcaI \xb8\x9c$\xb6\xef[\xbf\xdf'\xd5\x85\xf7\x91\xf8+k\xe8!\x7fa\xe3g\x1b\xc5;\xe1\x1fZ\x9d\xb8b\x03\xcdS\x05\xa6\x18\xafQ|\xb8*\xea\xc0\x8f\x9c\x1a\xb7\xb3\x1a\xb9\xe6\xa7\x98\x19\x9bG\xe1sF{\x9b&\xed\xe1R\x11\xec=\xf2\x03\xe93/J%\x16\x82Y\x90\x1f\x01\xf1\xa0\xf6\xdf\x0b21\xad\x80\xf6\xb2\xaf\xdeLo\xbb\xb5@O\xb2\xbf\xbe\x1b\x8f\xd5\xc5\xb3.@|\xf1r\xdc{\xc0(\x90uL\xd2\xb8mW\xbdz"

Using the code below, we successfully recover the same mainKey as previously (which was stored in plaintext in NoneProtection keychain entry):

How to crack PIN ?

If the PIN code is enforced, we must recover it in order to decrypt any data, including user informations. SCRYPT is a strong and slow key derivation function. Hashcat can not be used to simply bruteforce the PIN as the hash obtained by the derivation is used as an AES key, itself used to decrypt values. We are lucky here as mainKey is in a bplist format. Therefore, to get our stop condition, we have to:

  1. Derive the key from a pin candidate
  2. Decrypt PinProtection value with this intermediate key
  3. Check if we get a bplist header

We made a quick Python multiprocessing script to bruteforce a fixed length pin code which took around 5h to exhaust 6 digits on my Macbook Pro (2015):

Conclusion

ProtonMail iOS app relies mostly on the iOS platform security for the local storage protection, as they are stating. Additional PIN protection can add a complexity to recover the keys but on the condition to use a long one as it can be bruteforced offline.

Forensic examiners can therefore recover data from this application providing they have access to Full filesystem extraction and Keychain.

All the code here is fully open source and is provided as is. Do not hesitate to contact us for any question or go further in the app. Happy forensicating !!

UPDATE

(October 15 2021)

First, thanks to folks who implemented this research to iLEAPP (@TheKateCain and @AlexisBrigoni) !!

Biometrics

We confirmed that biometrics do not protect more than PIN. It is only a convenient way of having a strong PIN without the hassle. Therefore, a PIN and/or biometric protected mailbox will need the same bruteforcing.

Attachments

This research has been left incomplete as attachments were not decrypted. ProtonMail encrypts attachments also with PGP but the key packet is separated from the payload. Metadata are encrypted as well.

The database contains a ZATTACHMENT table with the metadata and foreign key to related message.

ZFILENAME, ZHEADER and ZMIMETYPE are encrypted bplist. ZLOCALURL is an unencrypted bplist holding path of attachment. These metadata are encrypted similarly as messages metadata, with AES CTR and mainKey (see above)

The files themselves are PGP encrypted with the recipients/sender public keys. However the keypacket is stored within the ZATTACHMENT table (ZKEYPACKET). In order to successfully decrypt an attachment, we thus need to concatenate this keypacket to the rest of data contained in the file indicated in ZLOCALURL.

Nota : Attachments have to be downloaded to be locally stored. Especially sent file are directly encrypted and not stored within the app.

So to decrypt attachments :

  1. Decrypt mainKey, and PGP Private key as explained above
  2. Get and decrypt file metadata (ZFILENAME, ZKEYPACKET and ZLOCALURL) from ZATTACHMENT table
  3. Get filedata from ZLOCALURL file
  4. PGPDecrypt(ZKEYPACKET|Filedata)

And here is the piece of code that should do the work (might need some tweaks ;)):

TGIF, seat back, relax and keep some work for Monday ;-)

--

--

Matthieu Regnery

I enjoy digital forensics, breaking things to understand how they work, reversing and desoldering. Keeping learning (PhD student at Lausanne University)