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 email
  • localMasterKeyHash 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 obvious
  • startLogIn will use tokenRequest 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:

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.

  1. Calling postIdentityToken with tokenRequest libs/common/src/services/api.service.ts#L190
  2. Data is extracted using toIdentityToken from PasswordTokenRequest libs/common/src/auth/models/request/identity-token/password-token.request.ts#L20
  3. .. which sets the password field to masterPasswordHash which is in fact serverPasswordHash computed earlier.

Recap:

  1. User password is hashed with a KDF
  2. serverPasswordHash is computed from previous hash
  3. 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.

  1. 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 a key parameter, among many other. They are stored using the IdentityTokenResponse structure libs/common/src/auth/models/response/identity-token.response.ts#L28
  2. The key is decrypted using a stretched masterKey libs/common/src/auth/services/master-password/master-password.service.ts#L181
  3. The client initiates a GET request on /api/sync to pull the whole database. libs/common/src/platform/sync/default-sync.service.ts#L119
  4. The data is read and synchronized locally into cipherServices libs/common/src/platform/sync/default-sync.service.ts#L303 using yet another data structure named CipherData
  5. Then I assume the view will load the cipher and decrypt it libs/angular/src/vault/components/view.component.ts#L131
  6. 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 called masterKey - remember how this.cache.value was set).
  7. Then decrypt is called libs/common/src/vault/models/domain/cipher.ts#L126
  8. Another more specific decrypt is called libs/common/src/vault/models/domain/login.ts#L53
  9. 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
  10. 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 ?)
  11. Eventually everything relies on decryptToUtf8 libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts#L66
  12. 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.

  1. The client makes a request to the server (hopefully using https)
  2. The server sends back a 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.
  3. 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)
  4. Regardless of the previous Kdf , serverMasterKeyHash is computed as PBKDF2(SHA256, masterKey, password, 1)
  5. serverMasterKeyHash is sent to the server to authenticate
  6. If authentication is successful, the server sends back an encrypted Key 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)
  7. 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.
  8. Ciphers are first checked with their MAC data as cipher_mac == HMAC_SHA256(cipher_iv || cipher_data, MacKey)
  9. 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!