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:
- It's not very fast ie. when starting the process I have to wait ~5 seconds before being prompted for my password (I solely blame the desktop client to be a Chromium browser running a JavaScript app)
- Navigation is not so easy with a keyboard only
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.
Diving in Bitwarden source code
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:
- Web client
- Desktop clients (Windows, MacOS, Linux)
- Browser extension
- CLI client
The clients communicates with the server using the server REST API (using HTTP and JSON).
Login mechanism
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:
masterKeyderivated from password and emaillocalMasterKeyHashderivated from password andmasterKeyserverMasterKeyHashderivated from password andmasterKeytokenRequestis generated from email andserverMasterKeyHashand passed tostartLogInthroughcache.next()which makes it really not obviousstartLogInwill usetokenRequestto log in to the server
As 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:
- The Key Derivation Function (KDF)
- The KDF iterations count
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.
- Calling
postIdentityTokenwithtokenRequestlibs/common/src/services/api.service.ts#L190 - Data is extracted using
toIdentityTokenfromPasswordTokenRequestlibs/common/src/auth/models/request/identity-token/password-token.request.ts#L20 - .. which sets the
passwordfield tomasterPasswordHashwhich is in factserverPasswordHashcomputed earlier.
Recap:
- User password is hashed with a KDF
serverPasswordHashis computed from previous hash- Hash is sent to server to validate authentication
The 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.
Database decryption
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.
- The server answers with an
access_tokenvalid for a certain amount of time that allows us to authenticate to read or write entries, and akeyparameter, among many other. They are stored using theIdentityTokenResponsestructure libs/common/src/auth/models/response/identity-token.response.ts#L28 - The
keyis decrypted using a stretchedmasterKeylibs/common/src/auth/services/master-password/master-password.service.ts#L181 - The client initiates a
GETrequest on/api/syncto pull the whole database. libs/common/src/platform/sync/default-sync.service.ts#L119 - The data is read and synchronized locally into
cipherServiceslibs/common/src/platform/sync/default-sync.service.ts#L303 using yet another data structure namedCipherData - Then I assume the view will load the cipher and decrypt it libs/angular/src/vault/components/view.component.ts#L131
- The decryption key is retrieved with again more unclear code (the
StateProviderMASTER_KEYfield was set earlier during login and it corresponds to what we calledmasterKey- remember howthis.cache.valuewas set). - Then
decryptis called libs/common/src/vault/models/domain/cipher.ts#L126 - Another more specific
decryptis called libs/common/src/vault/models/domain/login.ts#L53 - Which calls a more specific
decryptObjfunction libs/common/src/platform/models/domain/domain-base.ts#L49 which is hard to read due to a lot of messy JavaScript syntax - It should end up calling
EncString.decryptlibs/common/src/platform/models/domain/enc-string.ts#L154 (was it parsed from JSON here libs/common/src/vault/models/domain/cipher.ts#L265 ?) - Eventually everything relies on
decryptToUtf8libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts#L66 - It will do some AES-CBC and decrypt the content
Note 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.
Cryptanalysis
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.
- The client makes a request to the server (hopefully using https)
- The server sends back a
KdfIterationandKdffield such that the client knows either to use PBKDF2 or Argon2id, and how many times it should iterate. The default configuration for my vault is0aka PBKDF2 and600000iterations, so we will keep that for the analysis. - The client prompts the user password and email, and derives them using the information from the previous step, computing
masterKey = PBKDF2(SHA256, password, email, 600000) - Regardless of the previous
Kdf,serverMasterKeyHashis computed asPBKDF2(SHA256, masterKey, password, 1) serverMasterKeyHashis sent to the server to authenticate- If authentication is successful, the server sends back an encrypted
Keywhich is decrypted to get the decryption keyCipherKey. First a stretched key and mac key are computed:StretchedKey = HKDFExpandSHA256(masterKey, "enc")andMacKey = HKDFExpandSHA256(masterKey, "mac"). Then the received key is decrypted:CipherKey = AES256_CBC_Decrypt(StretchedKey, Key_iv, Key_data) - Ciphers (ie. names, notes, passwords, … - any string except dates) come with their encryption algorithm, IV, data and MAC data. The current default is AES256_CBC with HMAC_SHA256 but the codebase implements backwards compatibility with other schemes.
- Ciphers are first checked with their MAC data as
cipher_mac == HMAC_SHA256(cipher_iv || cipher_data, MacKey) - If successful, also decrypted as
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.
Conclusion
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 were 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!