Bitwarden code review
Published on 2024-06-30 by xarkes
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:
masterKey
derivated from password and emaillocalMasterKeyHash
derivated from password andmasterKey
serverMasterKeyHash
derivated from password andmasterKey
tokenRequest
is generated from email andserverMasterKeyHash
and passed tostartLogIn
throughcache.next()
which makes it really not obviousstartLogIn
will usetokenRequest
to 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
postIdentityToken
withtokenRequest
libs/common/src/services/api.service.ts#L190 - Data is extracted using
toIdentityToken
fromPasswordTokenRequest
libs/common/src/auth/models/request/identity-token/password-token.request.ts#L20 - .. which sets the
password
field tomasterPasswordHash
which is in factserverPasswordHash
computed earlier.
Recap:
- User password is hashed with a KDF
serverPasswordHash
is 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_token
valid for a certain amount of time that allows us to authenticate to read or write entries, and akey
parameter, among many other. They are stored using theIdentityTokenResponse
structure libs/common/src/auth/models/response/identity-token.response.ts#L28 - The
key
is decrypted using a stretchedmasterKey
libs/common/src/auth/services/master-password/master-password.service.ts#L181 - The client initiates a
GET
request on/api/sync
to pull the whole database. libs/common/src/platform/sync/default-sync.service.ts#L119 - The data is read and synchronized locally into
cipherServices
libs/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
StateProvider
MASTER_KEY
field was set earlier during login and it corresponds to what we calledmasterKey
- remember howthis.cache.value
was set). - Then
decrypt
is called libs/common/src/vault/models/domain/cipher.ts#L126 - Another more specific
decrypt
is called libs/common/src/vault/models/domain/login.ts#L53 - Which calls a more specific
decryptObj
function 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.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 ?) - Eventually everything relies on
decryptToUtf8
libs/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
KdfIteration
andKdf
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 is0
aka PBKDF2 and600000
iterations, 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
,serverMasterKeyHash
is computed asPBKDF2(SHA256, masterKey, password, 1)
serverMasterKeyHash
is sent to the server to authenticate- If authentication is successful, the server sends back an encrypted
Key
which 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!