🗝️

Bitwardenの実装から学ぶE2EE

2024/09/01に公開
1

この文章はなに?

本文章は、パスワードマネージャーであるBitwardenが公開しているソースコードを読み、そこでE2EE(End-to-end encryption)がどのように実装されているかについて、私が理解した内容をまとめたものです。
「E2EEをぼんやり理解してるが、どのように実装されているのかはわからない」という方を主な対象としています。

E2EEに対する私個人の課題感として、インターネット等から得られる説明が比較的抽象的であり、実装レベルでの理解が難しいというものがあります。
そこで私自身、そして同じ課題感を持つ方に向けて、E2EEを実践しているアプリケーションの1つであるBitwardenを参考に、それがどのように実装されているのかを詳細に理解すべく、本文章にまとめることとしました。

なお対象アプリケーションとしてBitwardenを選んだのは、私自身がユーザーであること、ソースコードが公開されており、実装を確認することができ都合がよかったためです。
E2EEの実装方法は様々であり、私はBitwardenの実装が唯一の正解であるとは考えていませんが、E2EEを実践し、実際にサービスとして運用されているものから学ぶことは非常に有意義であると考えています。

この文章で扱うこと・扱わないこと

Bitwarden、つまりパスワードマネージャを参考にする都合上、本文章ではE2EEの「エンド」を構成するユーザーエージェントが同一のものであるユースケースについて見ていきます。
つまり、ユーザーエージェントが送信した情報を、そのユーザーエージェント自身が受信するというようなパターンです。
同様のユースケースを持つ他のサービスとしては、クラウドストレージのようなものが挙げられます。

対して、「エンド」を構成するユーザーエージェントがそれぞれ異なる場合もあります。
代表的なものとしては、メッセンジャーアプリケーションです。
(E2EEと聞いたとき、こちらのユースケースを思い浮かべる人の方が多いかもしれません)
このようなアプリケーションにおけるユースケースについては、本文章では扱いません。
このようなパターンではユーザーエージェント間で鍵を適切に配送する必要があったりと、本文章とは異なる方針の設計が必要となるでしょう。

ユースケース毎の詳細

以降のセクションでは、Bitwardenにおける以下のユースケースについて見ていきます。

  • アカウントの作成
  • ログイン
  • 機密情報(Bitwarden上に保存するパスワードやセキュアメモ等)の暗号化と復号

いずれの場合においても、扱う情報が適切に暗号化されていることが期待されるため、それらがどのように実現されているのかを見ます。
他にも多数の機能がありますが、上記ユースケースを見れば、BitwardenにおけるE2EEの本質は把握できると考えています。なお、Bitwardenは多数のプラットフォームで利用できますが、本文章を書くにあたってはWebクライアント「Bitwarden Web Vault」での実装やシーケンスを参考にしています。以降、特に断りなくWebクライアントと書いた場合は、これを指すものとします。

理解を深めるため、暗号化や復号についてはソースコードを併記します。
これらはTypeScriptで記述しており、ランタイムはWebブラウザーを想定しています。
そのため、以下のように適当なファイルにコピーしたソースコードをJavaScriptにコンパイルすることで、手元のWebブラウザーでも動作を確認することができます。

 tsc --target es6 index.ts --outfile index.js

ソースコードはBitwardenと同一のものではなく、私自身で記述したものです。
これは単純化のためで、本質的な実行結果はBitwardenのそれと同じになるよう実装を確認の上、記述しています。なお、参考にした実装はBitwardenのWebクライアント、バージョン2024.8.0となります。
以降のセクションのいくつかの箇所では、実際にBitwardenのサーバーと送受信している情報と同様のものを、本文章のソースコードを使って適切に暗号化・復号できることを確認します。

アカウント作成

それでは、アカウント作成のユースケースから詳細を確認します。
Bitwardenを利用する場合、多くのWebサービスと同様、まずはアカウントを作成する必要があります。
この際に選べる認証の方法にはいくつかありますが、ここではメールアドレスとパスワードを用いるものと仮定します。それらをアカウント作成画面のフォームに入力し送信すると、アカウント作成が完了します。

この際、ユーザー・Webクライアント・サーバーがどのように関係するかをシーケンス図で示します。

ここには見慣れた単語もあれば、そうでない単語もあるでしょう。
図中の①から⑦のステップについて、順に見ていきます。

①フォーム入力

このステップは、単にWebクライアントが表示しているフォームに、ユーザーがメールアドレスとパスワードを入力することを示すものです。
ただしBitwardenでは、ここで決定した、ユーザーが覚えておく必要のあるパスワードを特にマスターパスワードと呼びます。本文章でも同様に、このパスワードのことをマスターパスワードと表記します。

②マスターキーを計算

ステップ①の後、Webクライアント上ではE2EEを実現するためのいくつかの鍵を生成します。
そのような鍵のうちの1つがマスターキーです。マスターキーは、主にマスターキー以外の鍵を暗号化し、それらを安全に通信経路に流したり、Bitwardenのサーバーに保管するために使われます。マスターキーは非常に重要なので、(Bitwardenのサーバーを含む)外部のサーバーに送信されたりすることはありません。
このような鍵は、より一般的にはラッピング鍵KEK(Key encrption key)と呼ばれるものに相当すると私は理解しています。本文章ではそれらの詳細には踏み込みませんが、文末に参考資料を挙げておきます。

マスターキーは、メールアドレスとマスターパスワードを元に鍵導出関数を使って生成されます。
それら2つを覚えておけば、どのようなデバイス上でもマスターキーは生成できます
Bitwardenでは、この際の鍵導出関数の入力パラメーターとして以下を与えるようになっています。

  • アルゴリズム: PBKDF2(SHA-256)
  • パスワード: マスターパスワード
  • ソルト: メールアドレス
  • 反復回数: 600,000

マスターキーの計算を具体的な実装で記述すると、次のようになります。なお、実装中に一度定義した型や関数は、それ以降のセクションでも特に断りなく利用します。

// 分かりやすさのため、いくつか鍵等についてブランド型を定義する
declare const __bland: unique symbol;
type Bland<B, T> = B & { [__bland]: T };

type MasterPassword = Bland<Uint8Array, "MasterPassword">;
type MasterKey = Bland<Uint8Array, "MasterKey">;

// マスターキーの計算
async function makeMasterKey(password: MasterPassword, mailAddr: Uint8Array): Promise<MasterKey> {
  return await computePbkdf2(password, mailAddr, 600000) as MasterKey;
}

// Web Crypto APIを使ってPBKDF2を計算する
async function computePbkdf2(password: Uint8Array, salt: Uint8Array, iter: number): Promise<Uint8Array> {
  const pbkdf2Params = {
    name: 'PBKDF2',
    hash: 'SHA-256',
    salt: salt,
    iterations: iter
  };

  const key = await crypto.subtle.importKey(
    'raw',
    password,
    'PBKDF2',
    false,
    ['deriveBits']
  );

  return new Uint8Array(
    await crypto.subtle.deriveBits(pbkdf2Params, key, 256)
  );
}

PBKDF2の計算にあたっては、Web Crypto APIのimportKeyderiveBitsを利用します。
利用方法自体はドキュメントに記載の範疇で、特別なものはありません。ここに限らず、Bitwardenのほぼ全ての暗号化・復号関連の処理はWeb Crypto APIのみを用い、サードパーティのライブラリは使用しません。

③マスターパスワードハッシュを計算

マスターキーの次にマスターパスワードハッシュを計算します。これはステップ⑤にてサーバーに送信する情報のうちの1つです。主にログイン時に参照される情報になるため、ここでは計算方法のみ確認します。

マスターパスワードハッシュは、マスターキーとマスターパスワードを入力パラメーターとしてPBKDF2を計算することによって得られます。具体的には、次の通りの入力パラメーターを与えた上で計算します。

  • アルゴリズム: PBKDF2(SHA-256)
  • パスワード: マスターキー
  • ソルト: マスターパスワード
  • 反復回数: 1

この時の1回の反復回数が適切かどうかは私の理解の及ぶところではありませんでした。ただ実装ではそのようになっており、Bitwardenの説明で、クライアントでの反復回数が600,001回という中途半端な数値になっているのはこのためのように思われます。以下はBitwardenセキュリティホワイトペーパーからの引用です。

PBKDF-SHA256は、マスターパスワードから暗号化キーを導き出すために使用されます。その後、このキーはBitwardenサーバーでの認証のためにソルト化され、ハッシュ化されます。PBKDF2で使用されるデフォルトの反復回数は、クライアント上で600,001回(このクライアント側の反復回数はあなたのアカウント設定から設定可能)、そして当社のサーバー上に保存される際に追加の100,000回(デフォルトでは合計700,001回)です。

マスターキーの計算において、私たちはPBKDF2を計算するための関数であるcomputePbkdf2を既に定義しているため、マスターパスワードハッシュの計算を実装するのは簡単です。実装は以下の通りとなります。

// Base64を簡単に計算できないため、エンコード・デコードの関数を自前で定義する
// 参考: https://developer.mozilla.org/ja/docs/Glossary/Base64
function encodeB64(bytes: Uint8Array): string {
  return btoa(Array.from(bytes, b => String.fromCodePoint(b)).join(''));
}

function decodeB64(b64: string): Uint8Array {
  return Uint8Array.from(atob(b64), (m) => m.codePointAt(0));
}

// マスターパスワードハッシュを計算し、そのBase64表現を返す
async function makeMasterPasswordHash(masterKey: MasterKey, password: MasterPassword): Promise<string> {
  return encodeB64(await computePbkdf2(masterKey, password, 1));
}

ここまでの実装を実際に手元の環境で動かしている方がいるのであれば、マスターパスワードハッシュの計算を通じてテストを行うことができます。以下に、私がローカルで実験的にセルフホストしているBitwarden上にて、メールアドレスをbw-selfhost@example.com、マスターパスワードをMyPa$$w0rdSamp1eとした際に、実際に生成されたマスターパスワードハッシュを示します。

6RErdmFgfWsUU1akSZuaVkUh2cKUdHNWHQmh8Q140xg=

ここまでの実装が正しければ、以下のようなテスト用の関数を実行した際に出力されるログには同様のマスターパスワードハッシュが含まれるはずです。また、このテストを通じて「メールアドレスとマスターパスワードが分かっているなら、どのデバイスでもマスターキー(及びマスターパスワードハッシュ)を生成可能」という点についても、理解が深まるかと思います。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;

  const masterKey = await makeMasterKey(password, mailAddr);

   // => 6RErdmFgfWsUU1akSZuaVkUh2cKUdHNWHQmh8Q140xg= と表示されるはず
  console.log('Master pasword hash: ' + await makeMasterPasswordHash(masterKey, password));
})();

④ユーザーキーを計算、暗号化

アカウント作成に必要な最後の情報がユーザーキーです。ユーザーキーは、私たちがBitwardenのサーバー上にパスワードやセキュアメモ(以降、機密情報と表記)を保管したり、またそのために通信経路に機密情報を流した際に、それが他者に読み取られないよう暗号化するための鍵です。
この鍵はマスターキーのように鍵導出関数で生成するのではなく、アカウント毎にCSPRNG(暗号論的擬似乱数生成器)を使ってランダムに生成されます。

ところでユーザーキーをランダムに生成してしまうと、アカウントを作成したデバイスとは別のデバイスからBitwardenを利用しようとした場合には、当然ユーザーキーが無いということになります。
そのため、アカウント作成時点でユーザーキーはマスターキーで暗号化され、サーバーに保管されます。
このようにしておけば、ユーザーはどのようなデバイスからでもサーバーに保管されたユーザーキーを取り出し、マスターキーで復号したのちに利用することができます。
先述の通りマスターキーが外部に送信されることはないため、他者が暗号化されたユーザーキーを得ることができたとしても、平文のユーザーキーを得るには至りません。

以下は私見になりますが、マスターキーとユーザーキーを分割することは、追加のセキュリティ上のメリットもあると考えられます。例えば「ユーザーキーが漏洩してしまったがマスターキーは漏洩していない」というようなケースについて考えてみましょう。この場合、機密情報は復号され得ますが、ユーザーキーからマスターキー(とマスターパスワードハッシュ)を得ることはできないため、アカウントにログインすることはできません。パスワードマネージャにとって機密情報が復号されることだけでも十分に重大だと言えますが、それでもログインが防止できるなら、多少は影響を軽減することができるかもしれません。このように、鍵を適切に派生・分割することは、リスク低減の観点からも重要であることがわかります。
鍵の派生・分割に関する考慮や、それらをしていなかったことによって起こった問題の例については、文末にも参考資料を挙げています。

それでは、まずは平文のユーザーキーを生成する実装を見ます。
Bitwardenでは、暗号化を行うための鍵と、暗号化後のMAC(Message Authentication Code)計算のための2つの鍵を1つにして鍵と呼ぶ場合があり、ユーザーキーもそのような鍵の1つです。
そのため、そのような2つの鍵を扱うためのクラスSymmetricKeyを定義します。
それぞれの鍵はWeb Crypto APIのgenerateKeyexportKeyを使い、AES-256-CBCの鍵として生成します。

type UserKey = Bland<SymmetricKey, "UserKey">;
type EncryptionKey = Bland<Uint8Array, "EncryptionKey">;

// 暗号化用の鍵とMAC計算用の鍵の2つを鍵として扱う
// 両者の鍵長は同じであるものとする
class SymmetricKey {
  enc: EncryptionKey;
  mac: EncryptionKey;

  constructor(enc: EncryptionKey, mac: EncryptionKey) {
    this.enc = enc;
    this.mac = mac;
  }

  // 暗号化用の鍵をWeb Crytpo API用にCryptoKeyオブジェクトにする
  async importEncKeyFor(usage: 'encrypt' | 'decrypt'): Promise<CryptoKey> {
    return crypto.subtle.importKey('raw', this.enc, { name: 'AES-CBC' }, false, [usage]);
  }

  // 2つの鍵を1つに結合
  toArray(): Uint8Array {
    return concatU8Array(this.enc, this.mac);
  }

  // toArrayによる結合を解く
  static fromArray(ary: Uint8Array): SymmetricKey {
    return new SymmetricKey(
      ary.slice(0, ary.length / 2) as EncryptionKey,
      ary.slice(ary.length / 2) as EncryptionKey,
    );
  }
}

// 2つのUint8Arrayを結合
function concatU8Array(a: Uint8Array, b: Uint8Array): Uint8Array {
  const c = new Uint8Array(a.length + b.length);
  c.set(a);
  c.set(b, a.length);

  return c;
}

// 暗号化用、又はMAC計算用の新しい鍵を生成する
async function generateNewAesKey(): Promise<EncryptionKey> {
  const key = await crypto.subtle.generateKey(
    { name: 'AES-CBC', length: 256 }, 
    true, 
    ['encrypt', 'decrypt']
  );

  return new Uint8Array(await crypto.subtle.exportKey('raw', key)) as EncryptionKey;
}

// 新しい鍵(SymmetricKey)を生成する
async function makeNewSymmetricKey(): Promise<SymmetricKey> {
  return new SymmetricKey(await generateNewAesKey(), await generateNewAesKey());
}

// 新しい鍵をユーザーキーとして生成する
async function makeNewUserKey(): Promise<UserKey> {
  return await makeNewSymmetricKey() as UserKey;
}

最後のmakeNewUserKey関数が、ユーザーキーを生成する関数です。暗号化用・MAC計算用のそれぞれについて、generateNewAesKey関数で新たに鍵を生成し、それを設定したSymmetricKeyオブジェクトをユーザーキーとして返しています。generateNewAesKey関数では、generateKeyでAES-256-CBC用として鍵を生成しています。
SymmetricKeyクラスには2つの鍵を1つに結合して扱うtoArrayfromArrayメソッドを実装しています。これは、この後マスターキーで暗号化する際に、このメソッドの実装の通り暗号化用・MAC計算用の鍵を1つに結合した状態で暗号化し、サーバーに送信するためです。
また、後々暗号化用の鍵をWeb Crypto APIで使えるよう、CryptoKeyオブジェクトとするためのimportEncKeyForメソッドも定義しています。

ユーザーキーを作ったら、後はマスターキーで暗号化すれば暗号化済みユーザーキーの完成です。
ただ、これは想像よりも少し複雑な手順が必要です。先ほど

Bitwardenでは、暗号化を行うための鍵と、暗号化後のMAC計算のための2つの鍵を1つにして鍵と呼ぶ

と書きました。しかしマスターキーは1つしかなく、これを暗号化用に使うとしてもMAC計算用の鍵がありません。そのため、Bitwardenでは鍵導出関数であるHKDFを使って、マスターキーを元に暗号化用とMAC計算用の2つの鍵を作ります。
しかし、Bitwardenで使っているこの処理はWeb Crypto APIに対応するAPIがないらしく、自前で実装されています。そのため、本文章でも似たような実装を記述することとしますが、HKDFの理論について詳しく説明することは私の理解の範疇を超えるため、詳細は割愛します。
RFCやBitwarden中の実装へのリンクは文末に記載したので、興味のある方はそちらをご確認ください。

// HKDFの展開ステップの実装
async function expandHkdf(prk: MasterKey, info: Uint8Array, okmBytes: number): Promise<EncryptionKey> {
  const n = Math.ceil(okmBytes / 32);
  const okm = new Uint8Array(okmBytes * n);
  
  let prevT = new Uint8Array(0);
  let okmOffset = 0;

  for (let i = 0; i < n && okmOffset < okmBytes; i++, okmOffset += prevT.length) {
    let msg = new Uint8Array(prevT.length + info.length + 1);
    msg.set(prevT);
    msg.set(info, prevT.length);
    msg.set([i + 1], msg.length - 1);

    prevT = await computeHmac(prk, msg);
    okm.set(prevT, okmOffset);
  }

  return okm.slice(0, okmBytes) as EncryptionKey;
}

// MAC計算。こちらは自前のHKDFとは特に関係なく、Web Crypto APIを使って計算する
// HKDFと、後々の暗号化後のMAC計算の両方で使用する
async function computeHmac(key: Uint8Array, msg: Uint8Array): Promise<Uint8Array> {
  const impKey = await crypto.subtle.importKey(
    'raw',
    key,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  return new Uint8Array(await crypto.subtle.sign('HMAC', impKey, msg));
}

// HKDFを使ってマスターキーから暗号化用とMAC計算用の2つの鍵を得て、SymmetricKeyとする
async function expandMasterKey(masterKey: MasterKey): Promise<SymmetricKey> {
  return new SymmetricKey(
    await expandHkdf(masterKey, new TextEncoder().encode("enc"), 32),
    await expandHkdf(masterKey, new TextEncoder().encode("mac"), 32)
  );
}

expandMasterKey関数が、マスターキーから暗号化用とMAC計算用の2つの鍵を派生させるための関数です。内部ではHKDFで鍵を派生するためのexpandHkdf関数を呼び出しています。鍵を派生させる際に情報文字列を入力することができ、Bitwardenの実装では暗号化用とMAC計算用のそれぞれについてencmacの固定の文字列が指定されているため、ここではそれに倣っています。
expandHkdf関数の実装にはMACの計算が必要であるため、それを行うcomputeHmac関数も定義しています。内部ではWeb Crypto APIのsignを使ってHMACを計算します。computeHmac関数は、この後の暗号文のMAC計算にも利用することになります。

これでユーザーキーを暗号化する準備ができました。最後に暗号化を行う実装を見ます。

// 暗号文を表す
class Encrypted {
  static readonly PREFIX: string = '2.';

  iv: Uint8Array;
  data: Uint8Array;
  mac: Uint8Array;

  constructor(iv: Uint8Array, data: Uint8Array, mac: Uint8Array) {
    this.iv = iv;
    this.data = data;
    this.mac = mac;
  }

  // 単一の文字列に変換する
  pack() {
    return Encrypted.PREFIX + encodeB64(this.iv) + "|" + encodeB64(this.data) + "|" + encodeB64(this.mac);
  }

  // packによる単一の文字列から、Encryptedオブジェクトに戻す
  static unpack(packed: string): Encrypted {
    const [iv, data, mac] = packed.substring(Encrypted.PREFIX.length).split('|')
    return new Encrypted(decodeB64(iv), decodeB64(data), decodeB64(mac));
  }
}

// 平文と鍵を受け取り、AES-256-CBCで暗号化した後、Encryptedオブジェクトとして返す
async function encrypt(plain: Uint8Array, key: SymmetricKey): Promise<Encrypted> {
  const iv = new Uint8Array(16);
  crypto.getRandomValues(iv);
  
  const cryptoKey = await key.importEncKeyFor('encrypt');
  const enc = new Uint8Array(
    await crypto.subtle.encrypt({ name: "AES-CBC", iv: iv }, cryptoKey, plain)
  );

  return new Encrypted(iv, enc, await computeHmac(key.mac, concatU8Array(iv, enc)));
}

// 暗号化された新規ユーザーキーを生成
async function makeEncryptedUserKey(masterKey: MasterKey): Promise<Encrypted> {
  return encrypt((await makeNewUserKey()).toArray(), await expandMasterKey(masterKey));
}

Encryptedクラスは、暗号化の結果として得られる暗号文を扱うクラスです。基本的にBitwardenにおける暗号文は、暗号化に使われたIVと、IVと暗号文から計算されるMACとセットで扱われます。また、サーバーへ送信する際には単一の文字列にパックされ、その際に以下のようなBitwarden固有のフォーマットに変換されます。

"{Type}.{Base64(IV)}|{Base64(Data)}|{Base64(MAC)}"

ここで、先頭のTypeは暗号化に使用されたアルゴリズムを示す整数です。使用され得るアルゴリズムと取り得る値についてはBitwardenの実装に列挙型として存在しますが、本文章では簡略化のためAES-256-CBC-HMAC-SHA-256を表す2をハードコードしています。

encrypt関数が、Web Crypto APIのencryptを利用して実際に暗号化を行う関数です。APIの利用方法はドキュメントに従い、AES-256-CBCで暗号化します。IVにはWeb Crypto APIのgetRandomValuesで取得したランダムな16バイトを使用します。
また、暗号化後はHKDFを計算する際にも使用したcomputeHmac関数でMACを計算します。メッセージはIVと暗号文を単に結合したもので、鍵はMAC計算用の鍵を使用します。

そして、これまでの全ての実装を統合し、暗号化されたユーザーキーを返す関数がmakeEncryptedUserKeyです。平文は新規ユーザーキーの暗号化用・MAC計算用の鍵を結合したもの、暗号化用の鍵はマスターキーからHKDFで派生したものです。これらを用いてencrypt関数で暗号化し、この関数が返すEncryptedオブジェクトのpackメソッドが返す文字列表現が、アカウント作成時にサーバーに送信される暗号化済みユーザーキーとなります。

ユーザーキー計算後のステップ(⑤~⑦)

ユーザーキー計算後のステップ⑤~⑦では、E2EEの実現に直接的に関係するような処理はほとんどありません。「⑥アカウントの作成」ではWebクライアントから送られてきた情報を適切にDBに保管するくらいですし、「⑦成否のレスポンス」についても、特に重要な情報をレスポンスしているわけでもありません。そのため、「⑤アカウント作成をリクエスト」で送るメールアドレス・マスターパスワードハッシュ・暗号化済みユーザーキーは結局どのようなものだったかを実装を含めて改めて確認し、次のユースケースの確認に進みましょう。

メールアドレスについては特に触れる必要はないでしょう。マスターパスワードハッシュはマスターキーとマスターパスワードを元にmakeMasterPasswordHash関数で、暗号化済みユーザーキーはマスターキーを元にmakeEncryptedUserKey関数で取得できることをここまでに見ました。前者はPBKDF2後者はAES-256-CBCによる暗号化が適用されているため、通信経路を流れたり、外部のサーバーに保管されても問題は生じません。これらは次のようなJSONへ変換され、Bitwardenのサーバーに送信されます。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;

  const masterKey = await makeMasterKey(password, mailAddr);
  const masterPasswordHash = await makeMasterPasswordHash(masterKey, password);
  const encryptedUserKey = await makeEncryptedUserKey(masterKey);

  console.log(JSON.stringify({
    email: new TextDecoder().decode(mailAddr),
    masterPasswordHash: masterPasswordHash,
    key: encryptedUserKey.pack()
  }, null, 2));
})();

上手く動作していれば、次のようなログが出力されます。ユーザーキーは毎回ランダムに生成されるため、恐らくkeyプロパティの値は異なるものになるでしょう。

{
  "email": "bw-selfhost@example.com",
  "masterPasswordHash": "6RErdmFgfWsUU1akSZuaVkUh2cKUdHNWHQmh8Q140xg=",
  "key": "2.e6GesVjB2gyrZOyFgX70cg==|myfLhGNr8GyzRqRzPGx57JXGt7UPuOATqkvJx9b/cEKp9yT86eL2KBPPOZCZlFYMs6r36ScM00G2MRasRJTz84vQI5F/scT1LbWD2AJcwEo=|m+eqWfX5D9g2+uE3SFaiGDvRXvseL87l69hjK4+5UQU="
}

このJSONは、Webクライアントがアカウント作成時に送信しているリクエストボディのそれとほとんど同様です。もし適当な環境でBitwardenサーバーをセルフホストしており、アカウントの作成をテストできるのなら、Webブラウザーのデベロッパーツールより、このようなリクエストボディが送信されていることを確認できるはずです。

ログイン

アカウントの作成を終えたユーザーは、登録の際に入力したメールアドレスとマスターパスワードを使って認証を行い、アカウントにログインすることができます。ログイン時のシーケンスは、主に以下に示す通りとなります。

認証後はBitwardenの様々な機能にアクセスできるようになりますが、E2EEの観点で最も重要なのは、アカウントの作成時にサーバーに保管した、暗号化済みユーザーキーの取得になるでしょう。
ここからは、最終的にWebクライアントがユーザーキーを手にするまでの間に、どのような手続きを踏むのかを中心に見ていきます。

①フォーム入力

アカウントの作成ユースケースの場合と同様、このステップは単にWebクライアントが表示しているフォームに、ユーザーがメールアドレスとマスターパスワードを入力することを示すものです。

②鍵導出関数について問い合わせ

アカウント作成ユースケースにて、私たちは、マスターキーがマスターパスワードとメールアドレスを元に鍵導出関数によって生成されることを見ました。
ログインユースケースにおいても、まずはマスターキーを生成したいのですが、Bitwardenでは鍵導出関数に与える入力パラメータのうちのいくつかをアカウント毎に変更し、サーバーに保管できる仕様になっているため、必ずしもWebクライアントが認識しているデフォルト値を鍵導出関数に与えればよいとは限りません。そのためBitwardenには、与えられたメールアドレスに紐付くアカウントの、マスターキーの導出時に与えるべき入力パラメータを返却するWeb APIが用意されています。
ステップ①によってメールアドレスが与えられた後は、まずはこのWeb APIにアクセスします。

③アカウントに紐付く鍵導出関数の情報を返却

サーバーに渡されたメールアドレスがアカウントに紐付けられている正しいメールアドレスであれば、保管されている鍵導出関数の入力パラメータが返却されます。鍵導出関数がPBKDF2の場合、ここで返される入力パラメータは反復回数のみです。

④マスターキーを計算

この時点で、メールアドレスとマスターパスワード、その他の鍵導出関数の入力パラメータが分かっているため、Webクライアント上ではマスターキーを計算できます。
マスターキーの計算方法はアカウントの作成時と同様です。私たちの実装でいうと、makeMasterKey関数にメールアドレスとマスターパスワードを与えれば計算できます。

⑤マスターパスワードハッシュを計算

マスターパスワードハッシュの計算も、アカウントの作成時と同様です。
マスターキーとマスターパスワードが分かっているため、makeMasterPasswordHash関数により計算できます。

⑥ログインリクエスト

マスターパスワードハッシュの計算が終わったら、メールアドレスと共にサーバーにログインリクエストとして送信します。この際にWebクライアントが送っている、実際のリクエストボディ(のサブセット)の生成は、これまでに定義した関数を使って以下のように実装できます。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;

  const masterKey = await makeMasterKey(password, mailAddr);
  const masterPasswordHash = await makeMasterPasswordHash(masterKey, password);

  // プロパティ名が気になるが、実際リクエストされるリクエストボディではこうなっている
  console.log(JSON.stringify({
    username: new TextDecoder().decode(mailAddr),
    password: masterPasswordHash
  }, null, 2));
})();

ログに出力されるJSONは、以下のようになるはずです。

{
  "username": "bw-selfhost@example.com",
  "password": "6RErdmFgfWsUU1akSZuaVkUh2cKUdHNWHQmh8Q140xg="
}

⑦認証処理

メールアドレスとマスターパスワードハッシュを受け取ったサーバーは、メールアドレスに紐付くアカウント情報をDBにクエリし、そのアカウントが持つマスターパスワードハッシュと、送られてきたそれを比較し、一致していれば認証成功とします。
このあたりは、E2EEというより、一般的なWebサービスにおけるパスワード認証と同様の処理だと理解しているので、特筆する点は特にありません。

⑧ログイン成否をレスポンス

サーバーは認証処理後にその成否をレスポンスし、もし認証に成功していたなら、今後BitwardenのWeb APIにアクセスするためのアクセストークン等に加えて、サーバーに保管していた暗号化済みユーザーキーを返します。

⑨ユーザーキーの復号

最後のステップとして、得られた暗号化済みユーザーキーをマスターキーで復号します。
これにより、Webクライアントは平文となったユーザーキーを得ることができ、ログイン後の各種機能を利用できるようになります。

復号は、基本的にはアカウントの作成時の暗号化に対応する形で復号を行うだけです。
ただし改竄検出のため、復号時にはMACを再計算する必要があります。以下に実装を示します。

// 暗号の復号
async function decrypt(enc: Encrypted, key: SymmetricKey): Promise<Uint8Array> {
  // IVと暗号文よりMACを再計算し、Encryptedオブジェクトが持つMACと比較して改竄を検出する
  // Bitwardenの実際の実装ではDouble HMAC Verificationというテクニックが使われる
  const actualMac = await computeHmac(key.mac, concatU8Array(enc.iv, enc.data));
  if (enc.mac.length != actualMac.length || !enc.mac.every((v, i) => v === actualMac[i])) {
    throw new Error('Invalid MAC!');
  }

  const cryptoKey =  await key.importEncKeyFor('decrypt');

  return new Uint8Array(
    await crypto.subtle.decrypt({ name: "AES-CBC", iv: enc.iv }, cryptoKey, enc.data)
  );
}

// 暗号化済みユーザーキーをマスターキーで復号する
async function decryptUserKey(masterKey: MasterKey, encryptedUserKey: Encrypted): Promise<UserKey> {
  const expanded = await expandMasterKey(masterKey);
  const userKey = await decrypt(encryptedUserKey, expanded);

  return SymmetricKey.fromArray(userKey) as UserKey
}

decrypt関数が復号のための主要な実装で、Web Crypto APIのdecryptを使って復号を実装します。decryptUserKey関数ではそれを使って暗号化済みユーザーキーをマスターキーで復号します。復号時にはMACを検証していますが、decrypt関数内にコメントしている通り、ここでのMACの比較は簡略化したものを使用しています。実際のBitwardenの実装では、タイミング攻撃を防ぐためDouble HMAC Verificationというテクニックが使われています。実装へのリンクは文末に記載しています。

最後に、私がセルフホストしているBitwardenサーバーから返された実際の暗号化済みユーザーキーをサンプルとし、それを復号して動作テストをしてみましょう。メールアドレスをbw-selfhost@example.com、マスターパスワードをMyPa$$w0rdSamp1eとしたアカウントで、暗号化済みユーザーキーは以下の通りでした。

2.jVFAJ+dTNJhw/zjwhlUSPw==|PPonCr3Toemr5+jSSIT4LNu50U7KSzEpIigG2FAHnrajmzRq/uJ7Obv5whizot6tEbcDPY4zETdw/7Wyq3Diq+o5HK4NpgrFG9PraCIHjx0=|F6nX1YrrIo3z/+Dpuicme/xq8eQreWlz1hQaB9B9xBg=

復号のテスト実装は以下の通りとなります。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;
  const masterKey = await makeMasterKey(password, mailAddr);

  const encryptedUserKey = Encrypted.unpack('2.jVFAJ+dTNJhw/zjwhlUSPw==|PPonCr3Toemr5+jSSIT4LNu50U7KSzEpIigG2FAHnrajmzRq/uJ7Obv5whizot6tEbcDPY4zETdw/7Wyq3Diq+o5HK4NpgrFG9PraCIHjx0=|F6nX1YrrIo3z/+Dpuicme/xq8eQreWlz1hQaB9B9xBg=');
  const userKey = await decryptUserKey(masterKey, encryptedUserKey);

  console.log('Decrypted: ' + encodeB64(userKey.toArray()));
})();

実行すると、次のようにログが出力されます。

Decrypted: UDZv+n73RX6GEL2V2GmXW/uEyPUCa6frCv6gKniQmGAu30/0X8pcwLzSpz8sjZpyLXHs2M5zR+HQSb8GUnbDnA==

上手く動いているように見えますが、果たして復号結果は正しいのでしょうか。
実は、Webクライアント上で認識されているユーザーキーを確認することができます。
Webクライアント上でログインし、ブラウザーのデベロッパーツールを開いて以下のJavaScript式を実行することで、現在クライアントで保持しているユーザーキーを確認することができるのです。

(await bitwardenContainerService.getCryptoService().getUserKey()).keyB64

結果は次のようになりました。出力されているBase64表現が一致しているため、復号処理が正しく行えていることがわかります。

機密情報の暗号化と復号

アカウントの作成とログインのユースケースを通じて、どのようなデバイス上でも、安全にマスターキーとユーザーキーを得られることを見ました。
ここからは機密情報の暗号化と復号のユースケースについて確認し、BitwardenがWebサービスのパスワードやセキュアメモをどのように扱っているかを見ていきます。
このユースケースは意外なほど簡単で、シーケンス図は用意していません。鍵で暗号化してサーバーに送る、それだけです。復号はこの逆を行うだけです。ただし使用する鍵については、ここまでに少し説明を省略していた部分があるので補足します。

ここまでユーザーキーについて、機密情報を暗号化するものとして説明していました。
これは実際には少し違っていて、BitwardenではWebサービス毎のパスワードやセキュアメモといった、機密情報1つ1つについて、それぞれ異なる鍵を使って暗号化を行います。この際に使われる鍵をサイファキーと呼びます。そしてユーザーキーは、このサイファキーを暗号化するためのラッピング鍵として使用されます。暗号化されたサイファキーは、サイファキーが暗号化した機密情報と一緒にサーバーに保管されます。つまりBitwardenでは、マスターパスワードからマスターキーを派生し、マスターキーでユーザーキーを暗号化し、ユーザーキーでサイファキーを暗号化し、最後にサイファキーで機密情報を暗号化するという鍵階層が構築されています。

このように鍵を派生・分割することについての私見は既に述べた通りです。サイファキーは機密情報毎に異なるため、1つのサイファキーが漏洩しても対応する機密情報以外に影響を与えることはありません。
また、機密情報毎に鍵が分割されていることは、利便性の面でもメリットとなる可能性があります。
例えば一部の機密情報を他のユーザーに共有したいという場合には、対象の機密情報のサイファキーのみを共有できれば可能となります。これは鍵が分割されているから可能なことで、仮に全ての機密情報を1つのユーザーキーで暗号化していた場合にはユーザーキーを共有しなければなりませんが、そうしてしまうと全ての機密情報を復号できてしまうので、このようなきめ細かい制御はできません。なお、もしかすると既にこのような共有機能はBitwardenにあるのかもしれませんが、私は把握していません。

機密情報の暗号化

それでは実装を見ていきます。暗号化された機密情報のフォーマットをBitwardenの保管APIの仕様に完全に合わせることは本質的ではないため、ここでは暗号化された機密情報の名前・内容・暗号化されたサイファキーを、それぞれnamenoteskeyプロパティに持つJSONを構成することを目標にします。ちなみに、このようなJSONは実際にBitwardenの保管APIに送信されている内容のサブセットとなります。

機密情報毎のサイファキーの生成には、既に実装したmakeNewSymmetricKey関数が使えます。つまり、処理の内容としてはユーザーキーの生成と同じです。それ以外にも、これまで見てきたクラスや関数を利用して、機密情報の暗号化は次のように記述できます。

interface EncrpytedCredential {
  name: Encrypted;
  notes: Encrypted;
  key: Encrypted;
}

async function encryptCredential(userKey: UserKey, name: string, notes: string): Promise<EncrpytedCredential> {
  const cipherKey = await makeNewSymmetricKey(); // 新規サイファキーを生成
  const encName = await encrypt(new TextEncoder().encode(name), cipherKey);
  const encNotes = await encrypt(new TextEncoder().encode(notes), cipherKey);

  return {
    name: encName,
    notes: encNotes,
    key: await encrypt(cipherKey.toArray(), userKey) // サイファキーはユーザーキーで暗号化
  };
}

encryptCredential関数を呼び出すと、暗号化された機密情報が返されます。以下のようにテストしてみましょう。メールアドレス・マスターパスワード・暗号化済みユーザーキーについてはこれまでもサンプルとして用いてきたものを使用しています。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;
  const masterKey = await makeMasterKey(password, mailAddr);

  const encryptedUserKey = Encrypted.unpack('2.jVFAJ+dTNJhw/zjwhlUSPw==|PPonCr3Toemr5+jSSIT4LNu50U7KSzEpIigG2FAHnrajmzRq/uJ7Obv5whizot6tEbcDPY4zETdw/7Wyq3Diq+o5HK4NpgrFG9PraCIHjx0=|F6nX1YrrIo3z/+Dpuicme/xq8eQreWlz1hQaB9B9xBg=');
  const userKey = await decryptUserKey(masterKey, encryptedUserKey);

  const cred = await encryptCredential(userKey, "機密情報", "あのサービスのパスワードはそれ");

  console.log(JSON.stringify({
    name: cred.name.pack(),
    notes: cred.notes.pack(),
    key: cred.key.pack(),  
  }, null, 2));
})();

上手く動作していれば、以下のように表示されるはずです。

{
  "name": "2.Zu+7HKvad3SIVLrWLh0trA==|ftW0OQ62eAakK2PZHvu+ppE1JI2yzis09UvTkJiOVyA=|aTuf1DWqXQFzr/ZRXjHIKusy8UuLlNFnNVacRZl6LPs=",
  "notes": "2.YqNJqDka4fYZ8AHf/8LWEA==|wn4UC3JBCbcu+aoZZScmXVTKRej/CPTnRcDXRv/cnWq+wnZq/kActvLjCRcZnDe5qbWqnqIjh3r8cYHDDKm3dpCIfEVn3fY/Mq3mhW6cHRqRwYbB6hvRnhpdywe3rZda/NxvJxScSzD4TOMDL51tpQ==|qut/KOGNIJJTLArvtB9y8SThvSMq1DSFJdfXLpjV+UU=",
  "key": "2.TK7ufNEtYvobkLKBBTGM8w==|W/LBNqS+nIVxrbwYRxVcjE+XPGu3b2t1lvhBntCb9yq7ziGbZ/C0gkf5NSdWqKlP7Eoje05M7FQsWTnAR0V1NaJL6iH7rVUNJfK72N9v8RY=|9EmOF3sJVOf6hHnsxzwJ3YJivZWTl1JhdxFn7dTcB0g="
}

全ての機密情報とサイファキーについて、Bitwarden特有のフォーマットをしており、暗号化されていることがわかります。このように暗号化された機密情報は、ユーザーキーを知らない限り復号されることはありません。そのため通信経路に流し、Bitwardenのサーバーで安全に保管することができます。実際のBitwardenでも、このフォーマットで機密情報をサーバーに送信しています。

機密情報の復号

復号も同様にシンプルです。サイファキーをユーザーキーで復号し、復号されたそれを使って名前と内容を復号すればよいです。

interface RawCredential {
  name: string;
  notes: string;
  key: SymmetricKey;
}

async function decryptCredential(userKey: UserKey, encrypted: EncrpytedCredential): Promise<RawCredential> {
  const cipherKey = SymmetricKey.fromArray(await decrypt(encrypted.key, userKey));
  const name = new TextDecoder().decode(await decrypt(encrypted.name, cipherKey));
  const notes = new TextDecoder().decode(await decrypt(encrypted.notes, cipherKey));

  return { name, notes, key: cipherKey };
}

復号の動作テストには、私の環境でセルフホストしたBitwarden上で実際に保管されている機密情報を使うことにしましょう。メールアドレス・マスターパスワード・暗号化済みユーザーキーは暗号化時のテストと同じものを使うことにします。以下が、実際に保管されている機密情報の名前と内容、そして暗号化されたサイファキーです。

# 名前
2.k7Aqi9FdADoP+AfnsJO7iw==|9HCDAmzxKHloR3XkuTSQMKGolRUdB5d1+Vh3uhO4v9XeNZKxJchehXhrMEGQMqag|L1W4pLWxv4RNGI8M9C6J/vDw+w6zqXvxkFIk+KUdZ1g=

# 内容
2.10IgakOmCkpGIipvs6Y9/w==|375hp7ekou/PSBCmRjlMHnQq//er47MnKX5lcX80L8rUN5Cb1BXaaQ7Ob/3SRO50|1nOjt3QEcqcyF5dJCiFaiURyByMb7QYx0rP9l3RMtD8=

# 暗号化されたサイファキー
2.p+Po+HNnv3NEzf1rQJEmpw==|AV2iCJJEQ1XmHuJG/bi4KX9HPZ3+H8y3JK7bD/kyl7MvIp/BMw/oRCDKeQwSszGYdE1eG+t9f6PbZLTo9xihqDRrn7pd2ShX3x+sdOvX9iA=|IPHP0LGFmRPUoFnC/qkMsqNdCPUjJ8OZXA2LsReyXr0=

テストのための関数は次の通りです。ぜひ自身で実行してみてください。

(async function() {
  const mailAddr = new TextEncoder().encode('bw-selfhost@example.com');
  const password = new TextEncoder().encode('MyPa$$w0rdSamp1e') as MasterPassword;
  const masterKey = await makeMasterKey(password, mailAddr);

  const encryptedUserKey = Encrypted.unpack('2.jVFAJ+dTNJhw/zjwhlUSPw==|PPonCr3Toemr5+jSSIT4LNu50U7KSzEpIigG2FAHnrajmzRq/uJ7Obv5whizot6tEbcDPY4zETdw/7Wyq3Diq+o5HK4NpgrFG9PraCIHjx0=|F6nX1YrrIo3z/+Dpuicme/xq8eQreWlz1hQaB9B9xBg=');
  const userKey = await decryptUserKey(masterKey, encryptedUserKey);

  const cred = await decryptCredential(userKey, {
    name: Encrypted.unpack('2.k7Aqi9FdADoP+AfnsJO7iw==|9HCDAmzxKHloR3XkuTSQMKGolRUdB5d1+Vh3uhO4v9XeNZKxJchehXhrMEGQMqag|L1W4pLWxv4RNGI8M9C6J/vDw+w6zqXvxkFIk+KUdZ1g='),
    notes: Encrypted.unpack('2.10IgakOmCkpGIipvs6Y9/w==|375hp7ekou/PSBCmRjlMHnQq//er47MnKX5lcX80L8rUN5Cb1BXaaQ7Ob/3SRO50|1nOjt3QEcqcyF5dJCiFaiURyByMb7QYx0rP9l3RMtD8='),
    key: Encrypted.unpack('2.p+Po+HNnv3NEzf1rQJEmpw==|AV2iCJJEQ1XmHuJG/bi4KX9HPZ3+H8y3JK7bD/kyl7MvIp/BMw/oRCDKeQwSszGYdE1eG+t9f6PbZLTo9xihqDRrn7pd2ShX3x+sdOvX9iA=|IPHP0LGFmRPUoFnC/qkMsqNdCPUjJ8OZXA2LsReyXr0=')
  });

  console.log('Decrypted credential', cred.name, cred.notes);
})();

振り返り

本文章を通じて、マスターキー・ユーザーキー・サイファキーといった多数の鍵が利用され、またそれによって機密情報がどのように暗号化されているかを見ました。
これまで見てきた実装はBitwardenの実際の実装の一部を完全に満たしており、本文章で扱った実装にネットワーク通信や後方互換等、アプリケーションとして成立させるための多数の実装を合わせたものが実際のBitwardenのWebクライアントになります。

なお、サーバー側の実装は特に確認していませんでしたが、理由はここまで実装を確認された方ならなんとなく理解いただけるかと思います。サーバー上では機密情報と鍵は全て暗号化されています。それらについて、サーバー上では一切の変更を行うことはできません(暗号化されたデータにはMACが付与されていたことを思い出してください)。そのため、サーバー上での暗号化されたデータについての処理は、適当なストレージに保管したり、Webクライアントの要求に応じてレスポンスしたりといった、よくある読み書き程度の処理しかできません。もちろん、そもそもサーバーとしてセキュアに運用するための様々な処置は取られているでしょうが、本文章で扱う範囲においては、E2EEという観点からはそこまで面白いものはなかったため、本文章では扱わないこととしました。

私見になりますが、このようなBitwardenの設計は、「マスターキーをルートのラッピング鍵とした、多層のエンベロープ暗号化によるセキュリティと利便性の実現」と言えるかなと思います。エンベロープ暗号化はAWS KMSやGoogle Cloud KMS等、クラウド事業者も提供している技術です。鍵を多量に扱うとなれば、暗号鍵管理といったトピックに興味が出てくるかもしれません。本文章が、「E2EE」から一歩踏み込んでみる機会となり、それ自体に対する理解の向上はもちろん、周辺技術を含めた理解や活用の一助となっていれば幸いです。

補足・参考資料

Discussion

murakmiimurakmii

文章公開時点の例示用メールアドレスについて、例示用としては相応しくないのではないかという観点から、bw-selfhost@example.comに変更させていただきました。
これに伴い、マスターパスワードハッシュやユーザーキー等も更新しています。
旧版を元に動作テストをされていた場合は齟齬が生じる可能性がありますので、改めて本文章中のソースコードをご確認いただければと思います。