ProtonMail offline password recovery
ProtonMail uses OpenPGP keys, but with custom bcrypt-based key derivation (prior to the usual s2k?). We can probably add a format that would crack ProtonMail login password (in their one-password mode, or the mailbox password otherwise?) given an OpenPGP private key extracted from a memory dump and bcrypt salt and SRP modulus obtained when trying to log in, as I explained here:
https://github.com/ProtonMail/WebClient/issues/38#issuecomment-811028444
Anyone wants to try and implement this?
FWIW, here's a first attempt at hacking our GPG format to support ProtonMail instead of normal GPG keys. This pretends to work (with --skip-self-test since the test vectors aren't updated), but I don't know if it actually works right or not.
diff --git a/src/gpg_common_plug.c b/src/gpg_common_plug.c
index 99618a2..2e38957 100644
--- a/src/gpg_common_plug.c
+++ b/src/gpg_common_plug.c
@@ -102,7 +102,7 @@ struct fmt_tests gpg_common_gpg_tests[] = {
};
// mul is at most (PLAINTEXT_LENGTH + SALT_LENGTH)
-#define KEYBUFFER_LENGTH ((PLAINTEXT_LENGTH + SALT_LENGTH) * 64)
+#define KEYBUFFER_LENGTH ((256 + SALT_LENGTH) * 64)
// Returns the block size (in bytes) of a given cipher
uint32_t gpg_common_blockSize(char algorithm)
@@ -574,7 +574,7 @@ static void S2KItSaltedSHA256Generator(char *password, unsigned char *key, int l
SHA256_Update(&ctx, "\0", 1);
}
// Find multiplicator
- tl = strlen(password) + SALT_LENGTH;
+ tl = 256/*strlen(password)*/ + SALT_LENGTH;
mul = 1;
while (mul < tl && ((64 * mul) % tl)) {
++mul;
@@ -583,7 +583,7 @@ static void S2KItSaltedSHA256Generator(char *password, unsigned char *key, int l
bs = mul * 64;
bptr = keybuf + tl;
n = bs / tl;
- memcpy(keybuf + SALT_LENGTH, password, strlen(password));
+ memcpy(keybuf + SALT_LENGTH, password, 256/*strlen(password)*/);
while (n-- > 1) {
memcpy(bptr, keybuf, tl);
bptr += tl;
diff --git a/src/gpg_fmt_plug.c b/src/gpg_fmt_plug.c
index 706c20b..9ee7ba2 100644
--- a/src/gpg_fmt_plug.c
+++ b/src/gpg_fmt_plug.c
@@ -37,6 +37,9 @@ john_register_one(&fmt_gpg);
#include <omp.h>
#endif
+#include <assert.h>
+#include "crypt_blowfish-1.3/crypt_blowfish.c"
+
#include "arch.h"
#include "params.h"
#include "common.h"
@@ -124,7 +127,25 @@ static int crypt_all(int *pcount, struct db_salt *salt)
int res;
unsigned char keydata[64];
- gpg_common_cur_salt->s2kfun(saved_key[index], keydata, ks);
+ static const uint8_t modulus[] = {put, your, 256, byte, values, here};
+ struct {
+ char bcrypt_out[60];
+ uint8_t modulus[256];
+ uint8_t i;
+ } buf;
+ assert(sizeof(buf) == 60 + 256 + 1);
+ BF_crypt(saved_key[index], "$2y$10$your.22.char.salt.here", buf.bcrypt_out, sizeof(buf), 0);
+ assert(sizeof(modulus) == sizeof(buf.modulus));
+ memcpy(buf.modulus, modulus, sizeof(buf.modulus));
+ char promises[4 * 64];
+ SHA512_CTX ctx;
+ for (buf.i = 0; buf.i < 4; buf.i++) {
+ SHA512_Init(&ctx);
+ SHA512_Update(&ctx, &buf, sizeof(buf));
+ SHA512_Final((unsigned char *)&promises[buf.i * 64], &ctx);
+ }
+
+ gpg_common_cur_salt->s2kfun(promises, keydata, ks);
res = gpg_common_check(keydata, ks);
if (res) {
cracked[index] = 1;
The below is copy-paste of my comment https://github.com/ProtonMail/WebClients/issues/38#issuecomment-811028444 referenced above, for archival here and to have everything in one place. I actually don't know whether ProtonMail still uses the same algorithm or has switched to something else since, but I guess at least for first login to an old account it has to use the old algorithm, so I guess this information is still relevant in case anyone wants to implement support in JtR (or a standalone cracker).
Historically, ProtonMail's derivation of the encryption key wasn't slow (but then the OpenPGP implementation probably used its own slow "s2k" algorithm anyway?) - this can still be seen in code as hashPassword0 and hashPassword1 (the current is hashPassword3). These older versions are kept in the code perhaps to support old ProtonMail accounts (are they getting upgraded to new hashing upon login?) Also, the plaintext password (base64-encoded) stayed in browser storage, per this old blog post (from 2015): https://arno0x0x.wordpress.com/2015/09/16/end2end-encryption-protonmail/ (way out of date, only included as a historical reference).
Things changed with @bartbutler's commit d510b25e6158ad4e9f7872641b5b74224cb9b11f in 2016, which introduced src/app/services/passwords.js implementing "hash version 3" building upon bcrypt:
function expandHash(str) {
return openpgp.util.concatUint8Array([
openpgp.crypto.hash.sha512(pmcrypto.binaryStringToArray(str + '\x00')),
openpgp.crypto.hash.sha512(pmcrypto.binaryStringToArray(str + '\x01')),
openpgp.crypto.hash.sha512(pmcrypto.binaryStringToArray(str + '\x02')),
openpgp.crypto.hash.sha512(pmcrypto.binaryStringToArray(str + '\x03'))
]);
}
function computeKeyPassword(password, salt) {
if (salt && salt.length) {
salt = pmcrypto.binaryStringToArray(pmcrypto.decode_base64(salt));
return bcrypt(password, "$2y$10$" + dcodeIO.bcrypt.encodeBase64(salt, 16)).then(function(hash) {
// Remove bcrypt prefix and salt (first 29 characters)
return hash.slice(29);
});
}
[...]
}
[...]
const hashPasswordVersion = {
4: function(password, salt, modulus) {
return hashPasswordVersion[3](password, salt, modulus);
},
3: function(password, salt, modulus) {
salt = pmcrypto.binaryStringToArray(salt + "proton");
// We use the latest version of bcrypt, 2y, with 2^10 rounds.
return bcrypt(password, "$2y$10$" + dcodeIO.bcrypt.encodeBase64(salt, 16)).then(function(unexpandedHash) {
return expandHash(unexpandedHash + pmcrypto.arrayToBinaryString(modulus));
});
},
I'm still confused about version 3 vs. 4 there (why two numbers for the same thing), and about direct use of bcrypt result in computeKeyPassword vs. having it passed through expandHash first.
Then this changed further in commit 0f505768e8e2496d448ee704c4e63407bbe0696b in 2019, where the above functionality got moved from WebClient into pm-srp (separate git repo here). In there, it's lib/passwords.js, which currently has:
/**
* Expand a hash
* @param {Uint8Array} input
* @returns {Promise<Uint8Array>}
*/
export const expandHash = async (input) => {
const promises = [];
const arr = concatArrays([input, new Uint8Array([0])]);
for (let i = 1; i <= 4; i++) {
promises.push(SHA512(arr));
arr[arr.length - 1] = i;
}
return concatArrays(await Promise.all(promises));
};
/**
* Format a hash
* @param {String} password
* @param {String} salt
* @param {Uint8Array} modulus
* @returns {Promise<Uint8Array>}
*/
const formatHash = async (password, salt, modulus) => {
const unexpandedHash = await bcrypt.hash(password, BCRYPT_PREFIX + salt);
return expandHash(concatArrays([binaryStringToArray(unexpandedHash), modulus]));
};
/**
* Hash password in version 3.
* @param {String} password
* @param {String} salt
* @param {Uint8Array} modulus
* @returns {Promise<Uint8Array>}
*/
const hashPassword3 = (password, salt, modulus) => {
const saltBinary = binaryStringToArray(salt + 'proton');
return formatHash(password, bcrypt.encodeBase64(saltBinary, 16), modulus);
};
I suppose it remained compatible with the 2017 code, although the code has changed.
Reviewing a memory dump of a Linux VM with Firefox that had been logged into ProtonMail for some days (and was also used for some other web browsing), I see several different (why?) private key blocks from "OpenPGP.js v4.10.8" (maybe some came from elsewhere? or does ProtonMail use multiple such keys at once?) and plenty of pieces of ProtonMail messages I had viewed or sent, in plaintext. I couldn't find the plaintext password (nor it base64-encoded), nor strings likely to be the bcrypt hash or a portion of it, although this last test is unreliable. Maybe only the result of expandHash reliably stays in memory? YMMV. There has to be something still in memory to decrypt one of those private keys, or an already decrypted key, but not necessarily something sufficient to re-login to ProtonMail.
I then tried relogging in to the account with a breakpoint set in Firefox debugger on the password hashing above. Without being authenticated yet, I could capture the account's bcrypt salt and modulus - this makes sense, as those are in fact needed on the client prior to authentication. I then tried searching the previously made memory dump above for the specific bcrypt salt string (22 chars) - not found.
I think the dump's encrypted(?) private keys plus the salt and modulus obtained when attempting to log in should allow for an offline attack on the password (process each candidate password with code similar to snippets above, then attempt to decrypt the private key with the result). That's fine.
I also tried to log in to a (presumably) non-existent account. It hit my breakpoint too, and there was a salt and modulus too. In fact, repeating that for the same non-existent account resulted in the same modulus (and same salt? not sure I checked that) as the first attempt for that account. This suggests sound engineering, where non-existent accounts appear indistinguishable (in this quick test of mine) from existing latest-version accounts. (I didn't double-check, though.)
I wish all of the above were documented by ProtonMail somewhere - or is it? I could only find high-level descriptions, not the level of detail I want. This public response to a paper is somewhat more detailed, but still isn't proper documentation and not detailed enough for me: https://protonmail.com/blog/cryptographic-architecture-response/
This is description is correct and remains so. Hash 3 and 4 are identical, the difference in this versioning was IIRC between using the hash to decrypt the user keyring and address keyrings directly (version 3) rather than decrypting the address keyrings with decrypted the user keyring as an additional layer (version 4), but there was no difference in the hashing. This upgrade indeed happened automatically on login and any account active in the last 7+ years would have had the upgrade. Hashes that can decrypt keys are indeed not sufficient to re-login to Proton because the salts different vs SRP, and we do indeed provide fake SRP parameters for non-existent accounts to disrupt enumeration attacks.