/index
Published on 2024-06-30
Since a few months I am using Bitwarden as my password manager. The main reason I started using it was that I wanted an easy way to keep my passwords synchronised, which local password managers like KeePassXC do not provide. You end up having to implement your own synchronization mechanism for instance storing the database file in a cloud synchronised folder like Google Drive, DropBox, NextCloud, etc.
Another reason is that Bitwarden is open-source and can be self-hosted, and since I enjoy losing time configuring my own infrastructure and wondering when my hard drive will die and if I made enough backups, I decided to use it for the past months.
I never took a glance at the implementation of Bitwarden but as my personal laptop is still my 9 year-old Lenovo X250, things around me are sometimes a bit laggy. I like when things are fast (when it comes to computers obviously) and the UIs I use responsive (as in fast). My current annoyances when it comes to the official Bitwarden client are the following:
When searching for an alternative desktop client on the web I found that some people were asking for it but there is actually none available. As an exercise and because I was wondering about the required effort to write a similar App using no web technologies, I started to dig into the source code in order to make a prototype implementation.
So, how hard can it be to review a JavaScript application when I spend most of my days reading C++? Surely it did not sound worse to me but I was very wrong. The review I am doing below is based on f0673dd16e1d5784c66b8fabae3121fb725ac028 as of June 29th 2024. The code base contains the source code for the following:
The clients communicates with the server using the server REST API (using HTTP and JSON).
The most simple login method is the password authentication which is implemented in libs/auth/src/common/login-strategies/password-login.strategy.ts
override async logIn(credentials: PasswordLoginCredentials) {
const { email, masterPassword, captchaToken, twoFactor } = credentials;
const data = new PasswordLoginStrategyData();
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
data.userEnteredEmail = email;
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
data.localMasterKeyHash = await this.cryptoService.hashMasterKey(
masterPassword,
data.masterKey,
HashPurpose.LocalAuthorization,
);
const serverMasterKeyHash = await this.cryptoService.hashMasterKey(
masterPassword,
data.masterKey,
);
data.tokenRequest = new PasswordTokenRequest(
email,
serverMasterKeyHash,
captchaToken,
await this.buildTwoFactor(twoFactor, email),
await this.buildDeviceRequest(),
);
this.cache.next(data);
const [authResult, identityResponse] = await this.startLogIn();
// [snip]
protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> {
await this.twoFactorService.clearSelectedProvider();
const tokenRequest = this.cache.value.tokenRequest;
const response = await this.apiService.postIdentityToken(tokenRequest);
if (response instanceof IdentityTwoFactorResponse) {
return [await this.processTwoFactorResponse(response), response];
} else if (response instanceof IdentityCaptchaResponse) {
return [await this.processCaptchaResponse(response), response];
} else if (response instanceof IdentityTokenResponse) {
return [await this.processTokenResponse(response), response];
}
throw new Error("Invalid response object.");
}
From the above code what I learned is we have the following:
masterKey
derivated from password and emaillocalMasterKeyHash
derivated from password and masterKey
serverMasterKeyHash
derivated from password and masterKey
tokenRequest
is generated from email and serverMasterKeyHash
and passed to startLogIn
through cache.next()
which makes it really not obviousstartLogIn
will use tokenRequest
to log in to the serverAs a first analysis we can see that things are really unclear and some things seem odd. Let's adjust our understanding and let's find out how the masterKey
is derivated finding makePreloginKey
. Grep is a really good friend when it comes to navigating the code base, because there are so many levels of abstractions that it's not obvious where the code would be. Here we find one implementation in libs/auth/src/common/services/login-strategies/login-strategy.service.ts#L240
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
email = email.trim().toLowerCase();
let kdfConfig: KdfConfig = null;
try {
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
if (preloginResponse != null) {
kdfConfig =
preloginResponse.kdf === KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
: new Argon2KdfConfig(
preloginResponse.kdfIterations,
preloginResponse.kdfMemory,
preloginResponse.kdfParallelism,
);
}
} catch (e) {
if (e == null || e.statusCode !== 404) {
throw e;
}
}
return await this.cryptoService.makeMasterKey(masterPassword, email, kdfConfig);
}
In fact this method will do another POST
request to the server in order to retrieve more information:
Already I feel a bit puzzled as I was expecting makePreloginKey
to… make a pre login key. Not another HTTP request. Eventually it will give me that key, but in terms of software readability, this is odd. Let's dig more and find the implementation of makeMasterKey
: libs/common/src/platform/services/crypto.service.ts#L257
/**
* Derive a master key from a password and email.
*
* @remarks
* Does not validate the kdf config to ensure it satisfies the minimum requirements for the given kdf type.
* TODO: Move to MasterPasswordService
*/
async makeMasterKey(password: string, email: string, KdfConfig: KdfConfig): Promise<MasterKey> {
return (await this.keyGenerationService.deriveKeyFromPassword(
password,
email,
KdfConfig,
)) as MasterKey;
}
Let's dig more: libs/common/src/platform/services/key-generation.service.ts#L40
async deriveKeyFromPassword(
password: string | Uint8Array,
salt: string | Uint8Array,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey> {
let key: Uint8Array = null;
if (kdfConfig.kdfType == null || kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = PBKDF2KdfConfig.ITERATIONS.defaultValue;
}
key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations);
} else if (kdfConfig.kdfType == KdfType.Argon2id) {
if (kdfConfig.iterations == null) {
kdfConfig.iterations = Argon2KdfConfig.ITERATIONS.defaultValue;
}
if (kdfConfig.memory == null) {
kdfConfig.memory = Argon2KdfConfig.MEMORY.defaultValue;
}
if (kdfConfig.parallelism == null) {
kdfConfig.parallelism = Argon2KdfConfig.PARALLELISM.defaultValue;
}
const saltHash = await this.cryptoFunctionService.hash(salt, "sha256");
key = await this.cryptoFunctionService.argon2(
password,
saltHash,
kdfConfig.iterations,
kdfConfig.memory * 1024, // convert to KiB from MiB
kdfConfig.parallelism,
);
} else {
throw new Error("Unknown Kdf.");
}
return new SymmetricCryptoKey(key);
}
Finally, we can be confident the masterKey
will be derivated from the master password using the email and either Argon2d or PBKDF2 as a KDF. Let's jump directly to startLogIn
and see what data is sent to the server.
postIdentityToken
with tokenRequest
libs/common/src/services/api.service.ts#L190toIdentityToken
from PasswordTokenRequest
libs/common/src/auth/models/request/identity-token/password-token.request.ts#L20password
field to masterPasswordHash
which is in fact serverPasswordHash
computed earlier.Recap:
serverPasswordHash
is computed from previous hashThe mechanism is very simple and yet not that easy to read from the code source. Also, we still have no idea what localPasswordHash
is used for.
Now that we know how the master password is sent to the server, let's see how passwords are decrypted. I will not quote all the code as previously as it is too heavy to read but rather just point out the interesting parts.
access_token
valid for a certain amount of time that allows us to authenticate to read or write entries, and a key
parameter, among many other. They are stored using the IdentityTokenResponse
structure libs/common/src/auth/models/response/identity-token.response.ts#L28key
is decrypted using a stretched masterKey
libs/common/src/auth/services/master-password/master-password.service.ts#L181GET
request on /api/sync
to pull the whole database. libs/common/src/platform/sync/default-sync.service.ts#L119cipherServices
libs/common/src/platform/sync/default-sync.service.ts#L303 using yet another data structure named CipherData
StateProvider
MASTER_KEY
field was set earlier during login and it corresponds to what we called masterKey
- remember how this.cache.value
was set).decrypt
is called libs/common/src/vault/models/domain/cipher.ts#L126decrypt
is called libs/common/src/vault/models/domain/login.ts#L53decryptObj
function libs/common/src/platform/models/domain/domain-base.ts#L49 which is hard to read due to a lot of messy JavaScript syntaxEncString.decrypt
libs/common/src/platform/models/domain/enc-string.ts#L154 (was it parsed from JSON here libs/common/src/vault/models/domain/cipher.ts#L265 ?)decryptToUtf8
libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts#L66Note that the answer from step 2 is in the following form:
{
"Ciphers": [
{
"Attachments": null,
"Card": null,
"CollectionIds": [],
"CreationDate": "2024-06-09T16:35:15.038251Z",
"Data": {
"Fields": null,
"Name": "2.49gWnoaK1nI5Pn/pSIOYrg==|5YpydD3KvqmmAej7fP/nkw==|+y1sS1Pi28xOPQT14k4UrYtc9TSJfgsf+nUjH3n/AAs=",
"Notes": "",
"Password": "2.49gWnoaK1nI5Pn/pSIOYrg==|YpifIojSBpof3EbQ0GyNBA==|0WBUgT11Hwi6Hb8H4bks+ICsMFBH88QMIGyemLBFYog=",
"PasswordHistory": null,
"Uri": null,
"Username": "2.49gWnoaK1nI5Pn/pSIOYrg==|ZUZugs7e8BsQ1Fe/qNAbfDO4fZUnsoq5Z55dcFDr37I=|dGIniQkSI7Xistm0KoJW8s6OfmGBS0OyfuBQCc3O52c="
},
"DeletedDate": null,
"Edit": true,
"Favorite": false,
"Fields": null,
"FolderId": null,
"Id": "c8d320e9-c4c3-446c-8ede-f322edf0a980",
"Identity": null,
"Key": null,
"Login": {
"Password": "2.49gWnoaK1nI5Pn/pSIOYrg==|YpifIojSBpof3EbQ0GyNBA==|0WBUgT11Hwi6Hb8H4bks+ICsMFBH88QMIGyemLBFYog=",
"Uri": null,
"Username": "2.49gWnoaK1nI5Pn/pSIOYrg==|ZUZugs7e8BsQ1Fe/qNAbfDO4fZUnsoq5Z55dcFDr37I=|dGIniQkSI7Xistm0KoJW8s6OfmGBS0OyfuBQCc3O52c="
},
"Name": "2.49gWnoaK1nI5Pn/pSIOYrg==|5YpydD3KvqmmAej7fP/nkw==|+y1sS1Pi28xOPQT14k4UrYtc9TSJfgsf+nUjH3n/AAs=",
"Notes": "",
"Object": "cipherDetails",
"OrganizationId": null,
"OrganizationUseTotp": true,
"PasswordHistory": null,
"Reprompt": 0,
"RevisionDate": "2024-06-17T20:30:24.341656Z",
"SecureNote": null,
"Type": 1,
"ViewPassword": true
},
{
// ... more ciphers
}
]
}
Bitwarden has 4 types of entries: identities, cards, logins and notes. The above entry is a login entry, and the server sends duplicate information for the name, password and username.
But oops, I forgot to mention the EncString
instantiation has to parse this format: 2.49gWnoaK1nI5Pn/pSIOYrg==|5YpydD3KvqmmAej7fP/nkw==|+y1sS1Pi28xOPQT14k4UrYtc9TSJfgsf+nUjH3n/AAs=
.
Indeed it is defined as ALGO . IV_b64 | DATA_b64 | MAC_b64
(well, almost: libs/common/src/platform/models/domain/enc-string.ts#L111 libs/common/src/platform/models/domain/enc-string.ts#L72)
That's it, I am a bit tired of trying to find my way around the code.
I am no cryptographer but as far as I know cryptography implementation mistakes lie in the details. So, let's recap with not too much details.
KdfIteration
and Kdf
field such that the client knows either to use PBKDF2 or Argon2id, and how many times it should iterate. The default configuration for my vault is 0
aka PBKDF2 and 600000
iterations, so we will keep that for the analysis.masterKey = PBKDF2(SHA256, password, email, 600000)
Kdf
, serverMasterKeyHash
is computed as PBKDF2(SHA256, masterKey, password, 1)
serverMasterKeyHash
is sent to the server to authenticateKey
which is decrypted to get the decryption key CipherKey
. First a stretched key and mac key are computed: StretchedKey = HKDFExpandSHA256(masterKey, "enc")
and MacKey = HKDFExpandSHA256(masterKey, "mac")
. Then the received key is decrypted: CipherKey = AES256_CBC_Decrypt(StretchedKey, Key_iv, Key_data)
cipher_mac == HMAC_SHA256(cipher_iv || cipher_data, MacKey)
clear = AES256_CBC_Decrypt(CipherKey, cipher_iv, cipher_data)
So the master password is derived 600,000 times which follows Owasp Password Storage cheatsheet and Bitwarden uses standard encryption algorithms. We could argue that the usage of AES CBC with HMAC is a bit clumsy as one would rather use a more modern alternative like AES GCM.
I consider the code of Bitwarden client to be of poor manufacture. I found it very hard to read, with too many layers of abstractions. To me, a software designed to securely store your most private things should be easy to read and easy to contribute to. In my opinion, finding where things where located in the code base was quite hard.
Here, the technical choice was to have an all-in-one application for the web and your desktop, which explains the usage of JavaScript (actually TypeScript, kudos!). Unfortunately I feel like although JavaScript is a super high-level language, the code was designed in a too hard to read fashion.
I started writing my own desktop client for the reasons I mentioned at the beginning of this article, using the Qt framework with C++. While it's not complete, you can check its implementation here. Is it easier to read? I hope so! Is it better? Not at all!