対象:
- サーバー実装: vaultwarden (
src/api/identity.rs,src/api/core/accounts.rs,src/api/core/ciphers.rs,src/db/models/cipher.rs) - クライアント実装: bitwarden-clients (
libs/auth,libs/key-management,libs/common/src/vault,libs/common/src/platform/sync)
スコープは「①マスターパスワードでログイン/ロック解除」〜「②保管庫のLogin項目(username/password)を平文で入手」まで。2FA・SSOは省略し、最もシンプルなマスターパスワードログインのケースを扱う。
本ドキュメントの目的: vaultwarden/Bitwardenのゼロ知識アーキテクチャがどのように成り立っているか、そしてその安全性がどこに由来し、どこに実装・運用上の注意点があるかを理解すること。末尾の「脅威モデル」セクションも、攻撃手順の解説ではなく、設計を正しく理解した上で自己ホスト運用時にどこを堅くすべきかを判断するための整理。
図は流れの全体像だけを示し、実際のAPIリクエスト/レスポンスの詳細(JSON例・フィールド名・実装箇所)は図の下の「詳細」セクションに①〜⑥の番号で対応させています。青い区間=クライアント/サーバー間の通信、オレンジの区間=クライアント内だけで完結するローカル計算(通信なし)です。
sequenceDiagram
actor U as ユーザー
participant C as クライアント
participant S as サーバー(vaultwarden)
rect rgb(219, 234, 254)
Note over C,S: ① KDF設定の取得(Prelogin)
U->>C: メール + マスターパスワード入力
C->>S: POST /identity/accounts/prelogin
S-->>C: KDF種別・iterations 等
end
rect rgb(254, 240, 199)
Note over C: ② Master Key / Master Password Hash 導出
C->>C: KDFで Master Key を導出
C->>C: Master Key から Master Password Hash を導出
end
rect rgb(219, 234, 254)
Note over C,S: ③ ログイン
C->>S: POST /identity/connect/token
S-->>C: access_token・Key・PrivateKey 等
end
rect rgb(254, 240, 199)
Note over C: ④ User Key / 秘密鍵の復号
C->>C: Master Key で Key を復号 → User Key
C->>C: User Key で PrivateKey を復号
end
rect rgb(219, 234, 254)
Note over C,S: ⑤ 保管庫データの同期
C->>S: GET /api/sync
S-->>C: 暗号化された Cipher 一覧
end
rect rgb(254, 240, 199)
Note over C: ⑥ Cipher の復号
C->>C: (必要なら) Cipher Key を復号
C->>C: username / password を復号
end
C-->>U: 平文の username / password を表示
リクエスト
POST /identity/accounts/prelogin
Content-Type: application/json
{ "email": "user@example.com" }
レスポンス
{
"kdf": 0,
"kdfIterations": 600000,
"kdfMemory": null,
"kdfParallelism": null
}kdf:0=PBKDF2-SHA256、1=Argon2id- サーバー:
POST /identity/accounts/prelogin(src/api/identity.rs:1026)→accounts::prelogin(src/api/core/accounts.rs:1324)。DBのUser.client_kdf_*を返す。未登録メールでもデフォルト値を返す(列挙攻撃対策)。 - クライアント:
libs/common/src/auth/password-prelogin/password-prelogin-api.service.tsが呼び出し、PreloginResponseとしてパース。 - ポイント:
saltは専用フィールドとして送られない(saltにはメールアドレス自体を使う)。
MasterKey = KDF(masterPassword, email, kdfConfig)(PBKDF2-SHA256 600,000回 or Argon2id) —key.service.ts: makeMasterKey()MasterPasswordHash = PBKDF2-SHA256(key=MasterKey, salt=masterPassword, 1回)—key.service.ts: hashMasterKey()- Master Keyはサーバーへ一切送信されない。 サーバーに送るのはMasterPasswordHash(認証専用の検証値)のみで、これは保管庫の暗号化には使われない。
リクエスト
POST /identity/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password
&username=user@example.com
&password=<MasterPasswordHash>
&scope=api offline_access
&client_id=browser
&deviceType=3&deviceIdentifier=<uuid>&deviceName=chrome
レスポンス
{
"access_token": "<JWT>",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "<opaque>",
"Key": "2.iv|encUserKey|mac",
"PrivateKey": "2.iv|encPrivateKey|mac",
"Kdf": 0,
"KdfIterations": 600000,
"UserDecryptionOptions": {
"MasterPasswordUnlock": {
"Salt": "user@example.com",
"MasterKeyEncryptedUserKey": "2.iv|..|.."
}
}
}grant_type=passwordのOAuth2互換Resource Owner Password Credentialsフロー。- サーバー:
ConnectData(src/api/identity.rs)で受信 →password_login()(362行)がDB保存済みpassword_hashと照合 →authenticated_response()(484行)がレスポンス生成。 Key= Master KeyでラップされたUser Key(DBのuser.akeyカラム)。PrivateKey= User Keyでラップされた RSA 秘密鍵。- クライアント:
IdentityTokenResponse(libs/common/src/auth/models/response/identity-token.response.ts)としてパース。
UserKey = decrypt(Key, MasterKey)— WASM SDKのPureCrypto.decrypt_user_key_with_master_password()(AES-256-CBC + HMAC-SHA256)PrivateKey(RSA) = unwrapDecapsulationKey(PrivateKey, UserKey)—key.service.ts: decryptPrivateKey()- この時点でUser Keyがメモリ上に保持される(=「ロック解除」完了状態)。マスターパスワード自体は以降保持し続ける必要がない。
リクエスト
GET /api/sync?excludeDomains=true
Authorization: Bearer <access_token>
レスポンス(抜粋)
{
"object": "sync",
"profile": { "id": "...", "email": "...", "key": "2...", "privateKey": "2..." },
"folders": [ ],
"ciphers": [
{
"id": "b1f2...",
"type": 1,
"name": "2.iv|encName|mac",
"login": {
"username": "2.iv|encUsername|mac",
"password": "2.iv|encPassword|mac",
"uris": [ { "uri": "2.iv|encUri|mac" } ]
},
"key": null,
"revisionDate": "2026-08-01T10:00:00Z"
}
]
}type: 1= Login。keyはCipher個別鍵(User Keyでラップ、無ければnull)。- サーバー:
GET /api/sync(src/api/core/ciphers.rs:121)→Cipher::to_json()(src/db/models/cipher.rs:147)。DBに保存済みの暗号文字列を復号せずそのまま返す(サーバーはUser Keyを持たないため復号不可能)。 login.username/login.password/uris[].uriはEncString形式"<encType>.<iv>|<data>|<mac>"。encType=2はAesCbc256_HmacSha256_B64。- クライアント:
libs/common/src/services/api.service.ts: getSync()が呼び出し、SyncResponse→CipherResponse[]に変換。
- Cipherが個別鍵を持つ場合:
CipherKey = unwrapSymmetricKey(cipher.key, UserKey)。持たない場合はUser Keyをそのまま使用。(libs/common/src/vault/models/domain/cipher.ts:149) username平文 = EncString.decrypt(login.username, CipherKey || UserKey)password平文 = EncString.decrypt(login.password, CipherKey || UserKey)(libs/common/src/vault/models/domain/login.ts: decrypt())- 結果として平文の
LoginView { username, password }が生成され、UIに表示される。
| 項目 | サーバーが保持 | クライアントのみが保持 |
|---|---|---|
| マスターパスワード | ✕ | ✓(入力時のみ、メモリ上) |
| Master Key | ✕ | ✓(メモリ上) |
| Master Password Hash | ✓(DB保存、検証用) | 送信時のみ |
| User Key(暗号化済み) | ✓(Key/akeyとして) |
復号後、平文User Keyを保持 |
| Cipherの平文(username/password) | ✕ | ✓(復号後、メモリ上) |
サーバーは常に「暗号化された状態」のデータしか保持・送受信せず、平文への復号は完全にクライアント側で行われる(ゼロ知識アーキテクチャ)。
このシーケンス図の範囲(自分のLogin項目をマスターパスワードで復号するだけ)では、PrivateKeyは使われない。username/passwordの復号はUser Key(対称鍵)だけで完結する。
ではなぜログイン時に毎回復号しているかというと、RSA鍵ペア(Public Key / Private Key)は「対称鍵を持たない相手から、自分にだけ復号可能な形で鍵を受け渡す」ための非対称暗号の仕組みで、以下の機能で使われるため(クライアントコード上の呼び出し元を確認済み)。
| 用途 | 実装箇所(クライアント) |
|---|---|
| 組織(Organization)への参加 — 招待時、Org KeyがあなたのPublic Keyで暗号化されて渡され、Private Keyで復号する | libs/common/src/admin-console/models/domain/encrypted-organization-key.ts |
| Emergency Access(緊急時アクセス) — 権限を託された側が、相手のPublic Keyで暗号化されたVault Keyを自分のPrivate Keyで復号する | apps/web/.../emergency-access/services/emergency-access.service.ts |
| 管理者によるパスワードリセット(Account Recovery) — 組織管理者があなたの鍵を復元できるよう、あなたのPublic Keyで暗号化された鍵を保持する仕組み | apps/web/.../organization-user-reset-password.service.ts |
| 新しいデバイスの承認(Login with device / Device Trust) — 新端末が生成した公開鍵で既存端末が鍵を暗号化して渡し、新端末側がPrivate Keyで復号する | libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts、libs/common/src/auth/.../auth-request.service.ts |
つまりPrivateKeyは「自分だけが持っていない相手(組織・別デバイス・緊急連絡先)から、事前の鍵共有なしに秘密を受け取るための鍵」。個人の保管庫アイテムの読み書きだけなら本質的には不要だが、ログイン直後にまとめて復号してメモリに保持しておくことで、これらの機能がいつ必要になっても即座に使える状態にしている。
libs/common/src/vault/models/domain/cipher.ts の decrypt() のロジックは以下の通り:
let cipherDecryptionKey = userKeyOrOrgKey; // デフォルトはUser Key(または所属組織のOrg Key)
if (this.key != null) {
const cipherKey = await encryptService.unwrapSymmetricKey(this.key, userKeyOrOrgKey);
cipherDecryptionKey = cipherKey; // Cipher Keyがあればそちらを使う
}- UserKey(またはOrgKey): アカウント全体(または組織全体)で共有される単一の対称鍵。
- CipherKey: Cipher(保管庫アイテム)1件ごとに個別発行される対称鍵。それ自体は
cipher.keyフィールドとして、User Key(個人所有アイテムの場合)またはOrg Key(組織所有アイテムの場合)でラップ(暗号化)された状態でサーバーに保存されている。
「どちらか一方を選ぶ」というより二段階になっている。
- まず
cipher.organizationIdの有無で「そのCipherを復号する土台の鍵」がUser KeyかOrg Keyか決まる(libs/common/src/vault/services/cipher.service.ts:1721,:1807)。 - その土台の鍵で
cipher.key(存在すれば)を復号してCipherKeyを得る。CipherKeyがあればusername/passwordなどの実フィールドはCipherKeyで復号し、cipher.keyがnull(per-item key機能導入前の古いアイテムなど)ならUser Key/Org Keyを直接使う。
CipherKeyを挟む理由は主に以下:
- 組織へアイテムを共有する際、フィールド全部を再暗号化せず、小さいCipherKeyだけをOrg Key向けに再ラップすれば済む(共有処理が軽い)。
- 万一1件のCipherKeyが漏洩しても、影響がそのアイテム1件に限定され、アカウント全体のUser Keyには波及しない。
「サーバーは常に暗号化されたデータしか扱わない」というゼロ知識設計は、サーバー側の情報漏洩(DB窃取・内部不正)に対しては非常に強い防御になる。一方で、この設計は攻撃対象をサーバーからクライアント側や運用面に押し出すという性質も持つ。ここでは「秘匿情報(保管庫の平文)の窃取」を目的とする攻撃者がどこに着目するかを、優先度順に整理する。各項目は攻撃解説ではなく、対応する緩和策とセットで理解する。
マスターパスワードさえ入手できれば、攻撃者は正規のログインフロー(①〜⑥)をそのままなぞるだけで全データに到達できる。複雑な暗号解析より遥かに費用対効果が高いため、最も狙われやすい。
- 経路: フィッシング、キーロガー、クリップボード監視、他サービスからの漏洩パスワードの使い回し
- 緩和策: マスターパスワードの使い回しをしない、フィッシング耐性のある2FA(WebAuthn/FIDO2)の併用、パスワードマネージャー自体へのアクセスをOS側の生体認証等でも保護する
フェーズ④の時点でUserKeyは復号された平文としてクライアントのメモリ上に常駐する。マスターパスワードを盗まなくても、この状態を狙えば同じ結果が得られる。
- 経路: 端末上のマルウェア(メモリダンプ)、Web Vault(SPA)へのXSS、悪意ある/脆弱なブラウザ拡張機能、端末ルート化・脱獄後のローカルストレージ解析
- 緩和策: 端末のマルウェア対策、ブラウザ拡張機能の権限を最小限にする、Web Vaultは信頼できるオフィシャルドメインのみで使う、端末紛失時のリモートログアウト/セッション無効化を利用する
「サーバーは復号できない」設計は裏を返せば「クライアントは必ず復号できる」ということでもあり、攻撃対象がサーバーからエンドポイント側に完全にシフトする点は理解しておく価値がある。
src/api/core/accounts.rs:643を確認すると、KDF iterationsの下限はPBKDF2の場合100,000回未満を拒否するのみで、これは現在の推奨値(600,000回)より大幅に緩い設定も許容してしまう。
POST /identity/accounts/prelogin(フェーズ①)は認証不要でメールアドレスを渡すだけでKDF種別・iterations数が返るため、対象アカウントの防御強度を外部から事前に確認できてしまう点も設計上の特徴として知っておくべき。DBが何らかの経緯で窃取された場合、iterations数が低いアカウントほどオフライン総当たりのコストが下がる。
- 緩和策: 全アカウントでKDF iterationsを現行推奨値(PBKDF2なら600,000以上、可能ならArgon2idへ移行)に統一する、既存アカウントのKDF設定を定期的に監査する、DB自体への不正アクセス経路(バックアップ管理、ネットワーク境界)を締める
src/api/identity.rsを確認した限り、ログイン失敗回数のカウントやアカウントロックアウトの仕組みはコード上に見当たらない。vaultwardenは伝統的に「レート制限はリバースプロキシ側で行う」という設計思想のため、対策なしで公開すると/identity/connect/tokenへの総当たりに弱くなり得る。
- 緩和策: リバースプロキシ(nginx等)やfail2ban等でレート制限・IPブロックを導入する、2FAを必須化する
src/config.rsには「平文のADMIN_TOKENはinsecure」という警告メッセージが実装されている。管理パネル(/admin)のトークンが弱い・平文・デフォルトのまま、あるいはネットワーク的に外部公開されていると、個々のユーザーの暗号突破を待たずに全ユーザーDB・設定へ到達されうる。
- 緩和策:
ADMIN_TOKENはArgon2 PHC形式のハッシュで設定する、管理パネルは信頼できるネットワーク(VPN/IP制限)からのみアクセス可能にする、不要なら管理パネル自体へのアクセスを外部から遮断する
新規デバイスの承認はPrivate Key(RSA)を使った非対称暗号でUserKeyを橋渡しする仕組み(前述のFAQ Q1参照)。ここはソーシャルエンジニアリング(正規ユーザーに誤って承認させる)を伴う攻撃面になり得る。
- 緩和策: デバイス承認リクエストの通知内容(デバイス種別・場所・時刻等)をよく確認してから承認する、身に覚えのない承認リクエストは必ず拒否する
暗号強度に関係なく、ユーザーが生成した非暗号化JSONエクスポートやアプリのローカルバックアップがディスク上に残っていれば、暗号を一切突破せずに済んでしまう。
- 緩和策: エクスポートファイルは用途が終わったら確実に削除する、暗号化エクスポート(パスワード保護つき)を利用する、バックアップの保存先自体を暗号化する
暗号ロジック自体(PBKDF2/Argon2id → AES-256-CBC+HMAC)を正面から破るのは非現実的であるため、実運用上のリスクは「復号済み平文が必ず存在する場所(クライアントメモリ・エクスポートファイル)」と「復号せずに済む近道(マスターパスワード窃取・弱いKDF設定・管理トークンの不備)」に集中する。ゼロ知識アーキテクチャはサーバー側の情報漏洩に対する防御としては非常に強力だが、それによって生じる「クライアント側・運用面の防御が相対的に重要になる」という設計トレードオフを理解しておくことが、自己ホスト運用における安全性評価の要点になる。