Closed10

Bitwardenのコードを読む

murakmiimurakmii

ローカルでセルフホストする

どこから読むかアタリを付けるため、とりあえずローカルでセルフホストする。
READMEを読む感じ、インストールスクリプトをポチポチするだけで良いらしい。
インストールスクリプトはbitwarden.shっぽく、この中でさらにrun.shを取得し、実行してインストールが進む。
なお、インストールの結果導入される一連のファイルは、bitwarden.shと同じディレクトリにbwdataという名前のディレクトリ以下に保存される。

途中以下のようにドメイン名とDB名の入力を求められる。

(!) Enter the domain name for your Bitwarden instance (ex. bitwarden.example.com):

(!) Enter the database name for your Bitwarden instance (ex. vault):

ローカルでセルフホストする場合、恐らくどちらも空欄で構わない。この場合、ドメイン名はlocalhost、DB名はvaultになる。
ドメイン名をlocalhost以外にするとLet's Encrypt関連の設定フローが始まってしまうため、特にこだわりがないならlocalhostが都合が良い。

入力後、bitwarden/setup Dockerコンテナイメージのpullが始まり、その後以下のようなプロンプトが表示される。

(!) Enter your installation id (get at https://bitwarden.com/host):

リンク先を読むと、一部機能であったりセキュリティ関連の周知を受け取るためにinstallation idとkeyを取得せよとのことだった。
省略できないかと実装を読んだが、どうも空欄にすることもできないようだったので、リンク先にてメールアドレスを入力し、installation idとkeyを取得して入力する。

最後に以下のように表示され、何らかの鍵を生成したことがわかる。

Generating key for IdentityServer.
...

実装では、openssl pkcs12コマンドでidentity.pfxというファイルを生成している。今の時点では、このファイルがどのように使われるのかはわからない。今後必要になったら調べる。

全て終わると、bitwarden.sh startでサーバーを起動せよと言われるので、その通りサーバーを起動すると各種Dockerコンテナが起動され、http://localhostでアクセスできるようになる。

アクセスログはbwdata/logs/nginx/access.logに出力されているようなので、次はWebから適当に操作し、吐き出されたログから読む実装を決め、読んでいく。

murakmiimurakmii

セルフホストしたDBの内容を確認する

セルフホストしたDBの内容を確認できるとコードリーディングが捗る。
bitwarden.sh startしているなら、Microsoft SQL Serverがbitwarden-mssqlという名前のDockerコンテナとして起動しているはずなので、このDBの内容を確認したい。

恐らく、ここの内容を元にDockerコンテナイメージがビルドされており、ベースイメージはmcr.microsoft.com/mssql/serverとなっている。
ドキュメントによるとsqlcmdが使えるそうなので、同一コンテナでシェルを立ち上げ、これを実行することでクエリを発行できる。

管理者アカウントのパスワードはコンテナ中の環境変数SA_PASSWORDで参照可能で、DB名はBitwardenインストール時に入力を省略しているならvaultとなっている。

$ docker exec -it bitwarden-mssql /bin/bash
# /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SA_PASSWORD -d vault
1> select name from sys.objects where type = 'U';
2> GO
name
--------------------------------------------------------------------------------------------------------------------------------
ProviderOrganization
Event
AuthRequest
OrganizationSponsorship
OrganizationDomain
SsoConfig
ProviderPlan
Migration
Cipher
Collection
CollectionCipher
CollectionGroup
CollectionUser
Device
ProviderInvoiceItem
Secret
Folder
Project
Grant
Group
ProjectSecret
GroupUser
SsoUser
Installation
ServiceAccount
Organization
Transaction
ApiKey
OrganizationUser
User
Cache
AccessPolicy
OrganizationApiKey
Send
OrganizationConnection
Policy
WebAuthnCredential
Provider
TaxRate
ProviderUser
EmergencyAccess

(41 rows affected)
murakmiimurakmii

アカウント作成処理を読む

/#/registerを開くとアカウントを作成するためのフォーム画面が開く。
適当にフォームを埋め、「アカウントの作成」を押下するとPOST /identity/accounts/registerがリクエストされ、アカウントが作成される(これ以外に特にリクエストは送信されていない様子)
この時のマスターパスワードの扱い等を知りたいので、実装を読む。

簡易まとめ

長くなったので主にクライアントで作っていた鍵をまとめておく。

  • マスターキー: マスターパスワードとメールアドレスから作る鍵。サーバーにはこれをPBKDF2に通した値しか保存しない
  • ユーザーキー: アカウント作成時にランダムに生成する鍵。マスターキーで暗号化してサーバーに保存する
  • キーペア: アカウント作成時にランダムに作成するキーペア(RSA-OAEP)。公開鍵と秘密鍵をサーバーに保存する。秘密鍵はユーザーキーで暗号化する

クライアント側

クライアントからは、以下の内容をリクエストボディとして送信している。

{
    "email":"foobar@example.com",
    "name":"murakmii",
    "masterPasswordHash":"...",
    "key":"...",
    "referenceData":{"id":null,"initiationPath":"Registration form"},
    "captchaResponse":null,
    "kdf":0,
    "kdfIterations":600000,
    "masterPasswordHint":null,
    "keys":{
        "publicKey":"...",
        "encryptedPrivateKey":"..."
    }
}

以下のプロパティに興味があるので、生成方法を読む。

  • masterPasswordHash
  • key
  • keys.publicKey
  • keys.encryptedPrivateKey

これらはRegisterComponentクラスのbuildRegisterRequestメソッドで生成されているため、ここからさらにそれぞれの値の生成方法を読む。

masterPasswordHash

この値の前にマスターキーと呼ばれる鍵を生成しているので、まずはそこから読む。
マスターキーはCryptoServicemakeMasterKeyメソッドにフォームに入力したマスターパスワードとメールアドレス、鍵導出関数の設定を渡して生成する。
鍵導出関数の設定では、PBKDF2-SHA256を、60万回のイテレーションで使用することが指示される。

makeMasterKeyメソッドに渡された値はなんやかんやでWebCryptoFunctionServiceクラスのpbkdf2メソッドに渡され、このメソッドの戻り値がマスターキーとなる。ここに至るまで、特に目立った処理はない(ただし鍵導出関数にはArgon2も指定可能で、PBKDF2の場合と微妙に処理が異なる
このメソッドはパスワードとソルト(と、その他)を取るが、ここにはマスターパスワードとメールアドレスを使う。ソルトはランダムではない。そのため、あるアカウントに対応するマスターキーは、マスターパスワードかメールアドレスを変更しない限り常に同一となる。

pbkdf2メソッド内ではブラウザ標準のSubtleCrypto APIを使う。importKeysでマスターパスワードを鍵として取り込み、deriveBitsでマスターキーを生成する。

そして、このように生成したマスターキーを、再度WebCryptoFunctionServiceクラスのpbkdf2メソッドのベース鍵として生成した値がmasterPasswordHashとなる(RegisterComponentクラスからはCryptoServicehashMasterKeyメソッドを呼んでいるが、このメソッド内で目立った処理は行っていない
なお、この時のソルトにはマスターパスワードを使う。

key

このプロパティには、ユーザーキーと呼ばれる鍵の、暗号化したバージョンが設定される。
ユーザーキーは、CryptoServicemakeUserKeyメソッドで生成する。

鍵はKeyGenerationServiceクラスのcreateKeyメソッドと、そこから呼ばれるWebCryptoFunctionServiceクラスのaesGenerateKeyメソッドから生成される。前者はほぼ後者を呼び出すだけで、後者ではSubtleCrypto APIgenerateKeyexportKeyを使って鍵を得る。
generateKeyにはアルゴリズムとしてAES-CBC、鍵長256ビットを指定している。ただ、ユーザーキーとしては鍵長が512ビット必要で、generateKeyを2回呼び出し結果を結合して鍵としている(単にランダムなバイト列が欲しいだけ?

ユーザーキーの暗号化にはEncryptServiceImplementationクラスのencryptメソッドが使われる。色々なメソッドを呼び出して暗号化するが、おおよそ以下の要件で暗号化する。

  • SubtleCrypto APIのencryptを使用
  • アルゴリズムはAES-CBC
  • 鍵はマスターキー
  • IVはCrypto APIのgetRandomValuesで作った16バイト

暗号化されたユーザーキーは、アルゴリズムやIVと共に単一の文字列にエンコードされる。この際のフォーマットはEncryptionType列挙型にコメントされている。

<アルゴリズム>.<IV>|<暗号化されたデータ>|<MAC>

サーバーに送信されるユーザーキーは、この文字列となる。

keys.publicKeykeys.encryptedPrivateKey

これらの鍵はCryptoServicemakeKeyPairメソッドで作られる。
実際にはWebCryptoFunctionServiceクラスのrsaGenerateKeyPairメソッドで大半の処理を行っており、こちらではSubtleCrypto APIのgenerateKeyexportKey使ってキーペアを作成する。おおよその要件は以下の通り。

  • 鍵長2048ビットのRSA-OAEP
  • 公開鍵はSPKI形式
  • 秘密鍵はPKCS #8形式

サーバーに送信されるのはこれらキーペアだが、秘密鍵は送信前に暗号化される。
暗号化にはユーザーキーと同様EncryptServiceImplementationクラスのencryptメソッドが使われるが、鍵にはマスターキーではなくユーザーキーの平文が使われる。

サーバー側

サーバー側の主要処理はRegisterUserCommandクラスのRegisterUserWithOptionalOrgInviteから始まる。
ただ、サーバー側では送られてきたデータを単に永続化しているだけで、あまり読むべき箇所がない。
ログイン処理はアカウント登録とは別のリクエストで行っているようで、この時点では行われない。

murakmiimurakmii

ログイン処理を読む

クライアント側

ログイン画面(/#/login)でメールアドレスとマスターパスワードを入力して「マスターパスワードでログイン」を押下すると2つのリクエストが飛び、認証される。

POST /identity/accounts/prelogin

このAPIに関するリクエストボディとレスポンスボディは以下の通りで、ログインに使用しようとしているメールアドレスを取り、マスターキーの導出に使う鍵導出関数とそのパラメータを返す。

# リクエストボディ
{email: "foobar@example.com"}

# レスポンスボディ
{"kdf":0,"kdfIterations":600000,"kdfMemory":null,"kdfParallelism":null}

マスターキーの導出方法はクライアントの設定によりまちまちなので、認証前にこれを取得できる必要があるのだろう。 このAPIは恐らく単にメールアドレスでDBをクエリして、保存されている鍵導出関数に関する設定を返しているだけだと思うので、特に興味がわかないので実装は確認しない。

POST /identity/connect/token

実際に認証を行うのはこちらのAPIになる。
リクエストボディとレスポンスボディは以下の通り。

# リクエストボディ
# 実際には application/x-www-form-urlencoded でエンコードされているが、分かりやすいよう整形
scope: api offline_access
client_id: web
deviceType: 9
deviceIdentifier: ...
deviceName: chrome
grant_type: password
username: foobar@example.com
password: ...

# レスポンスボディ
{
    "access_token": "eyJhbG...",
    "expires_in": 3600,
    "token_type": "Bearer",
    "refresh_token": "25...",
    "scope": "api offline_access",
    "PrivateKey": "2.eR...",
    "Key": "2.LU...",
    "MasterPasswordPolicy": null,
    "ForcePasswordReset": false,
    "ResetMasterPassword": false,
    "Kdf": 0,
    "KdfIterations": 600000,
    "KdfMemory": null,
    "KdfParallelism": null,
    "UserDecryptionOptions": {
        "HasMasterPassword": true,
        "Object": "userDecryptionOptions"
    }
}

クライアント側の処理、つまりリクエストボディの生成においては、passwordをどのように生成しているのかが気になる。
また、レスポンスされたデータの扱いについては、PrivateKeyプロパティやKeyプロパティが何で、どのように扱っているかが気になるので、それぞれ読んでいく。

passwordリクエストパラメータ

認証のフローは大体PasswordLoginStrategyクラスのlogInメソッドに書かれているので、そのあたりを読んでいく。

まずクライアントでは、アカウント作成時と同様の手順でマスターキーを生成する。
マスターキーは、マスターパスワードとメールアドレス、鍵導出関数とそのパラメータがわかれば常に同じものを生成できる。
LoginStrategyServiceクラスにあるmakePreloginKeyメソッドがこれらの処理を行う。
このメソッドの処理の一環として、POST /identity/accounts/preloginもリクエストされる。
最終的なマスターキーの生成は、アカウント作成時と同じCryptoServicemakeMasterKeyメソッドによって行われる。

マスターキーを作ったら、やはりアカウント作成時と同様CryptoServiceクラスのhashMasterKeyメソッドを通してpasswordリクエストパラメータの値とする。

つまり、端的に言うとアカウント作成時のmasterPasswordHashと同じ値となり、認証処理はこの値を使って行われている。

レスポンスされたPrivateKeyプロパティとKeyプロパティ

リクエストボディを作った後はLoginStrategyクラスのstartLoginメソッドにてリクエストを送信し、レスポンスを同クラスのprocessTokenResponseメソッドで処理する。

このメソッドの処理の過程で、PasswordLoginStrategyクラスのsetMasterKeyメソッドやsetUserKeyメソッドを呼び出し、メモリ上に鍵を保存する。

setMasterKeyメソッドはその名前の通りマスターキーをメモリ上に保存するものだが、マスターキーはローカルにのみ存在する鍵なので、特にレスポンスの内容を使うことはない。

setUserKeyメソッドは、レスポンスされたKeyプロパティをユーザーキーとしてメモリ上に保存する。このユーザーキーは、アカウント作成時にマスターキーで暗号化し、サーバーに送信したユーザーキーのことである。

PrivateKeyプロパティをメモリ上に保存するsetPrivateKeyメソッドもあるが、これだけだと何かわからないのでサーバー側を読んで確認する。

Bitwardenの解説にある通り、マスターパスワードはメモリ上から速やかに削除されるようだが、マスターキーやユーザーキーはそれなりの期間、メモリ上に保存している模様(説明の通り、ロック時にクリアする処理もありそう

サーバー側

認証回りの処理はほぼライブラリに任せているようで、あまり読みたいと思う箇所がない。
PrivateKeyプロパティが何だったのかだけ確認する。
このプロパティの値はBaseRequestValidatorクラスの実装で生成しているっぽく、UserクラスのPrivateKeyフィールドの値を設定しているようである。
そもそもUserクラスのインスタンスの各種値はアカウント作成時に設定しているはずなので、改めて確認してみると、アカウント作成時のkeys.encryptedPrivateKeyPrivateKeyに設定していた。

まとめ

というわけで、ログイン時はマスターキーのハッシュ値で認証し、その際サーバーに保存していた以下の値を取得してメモリ上に保持するということが分かった。

  • ユーザーキー
  • キーペアのうち秘密鍵

また、認証の過程でマスターキーも生成しているので、それもメモリ上に保持していることが分かった。次はこれらを使ってパスワードをどのようにサーバーに保存するのかを読みたい。

murakmiimurakmii

アイテムの保存処理を読む

ログイン後、画面右上の「新規作成」を開いて「アイテム」をクリックし、適当にフォームを入力して「保存」をクリックするとリクエストPOST /api/ciphersが発生し、サーバーへアイテムが保存される。
その際のリクエストボディとレスポンスボディは以下のようなJSONとなっている。
(ここではセキュアノートとし、名前を「サンプル」、メモを「メモだよ」と入力した)

# リクエストボディ
{
  "type": 2,
  "organizationId": null,
  "name": "2.gtrSj...",
  "notes": "2.lr4l7t...",
  "favorite": false,
  "lastKnownRevisionDate": null,
  "reprompt": 0,
  "key": "2.XDYF...",
  "secureNote": {
    "response": null,
    "type": 0
  }
}

# レスポンスボディ
{
    "folderId": null,
    "favorite": false,
    "edit": true,
    "viewPassword": true,
    "id": "d40b6b1d-b4b2-40ad-bdb9-b1da00d6c5da",
    "organizationId": null,
    "type": 2,
    "data": {
        "type": 0,
        "name": "2.gtrSj...",
        "notes": "2.lr4l7t...",
        "fields": null,
        "passwordHistory": null
    },
    "name": "2.gtrSj...",
    "notes": "2.lr4l7t...",
    "login": null,
    "card": null,
    "identity": null,
    "secureNote": {
        "type": 0
    },
    "fields": null,
    "passwordHistory": null,
    "attachments": null,
    "organizationUseTotp": false,
    "revisionDate": "2024-08-28T13:01:57.8332161Z",
    "creationDate": "2024-08-28T13:01:57.833216Z",
    "deletedDate": null,
    "reprompt": 0,
    "key": "2.XDYF...",
    "object": "cipher"
}

一見して、リクエストボディに重要なデータが平文で入っている様子はない。
namenoteskeyのように数字とピリオドから始まっている文字列は、アカウント作成処理で見た通り、暗号化されたデータに特有のBitwarden固有のフォーマットであることから、重要な情報全てに暗号化が施されていることがわかる。
レスポンスボディは、単に作成したアイテムの内容を反芻しているだけのように見える。

というわけで、まずはクライアント側でどのような暗号化を施しているのかを中心に見ていく。

クライアント側

クライアント側の処理は、AddEditComponentクラスのsubmitメソッドから始まる。
フォームに入力された内容を元にCipherViewクラスのインスタンスを生成し、encryptCipherメソッドを呼び出す。このメソッドは単にCipherViewクラスのインスタンスを引数にCipherServiceクラスのencryptメソッドを呼び出す。
encryptメソッドは引数を4つ取るが、第一引数のCipherViewクラスのインスタンス以外は全て省略している。

encryptメソッドでは、CipherViewクラスのインスタンスからCipherクラスのインスタンスを生成する。これらのクラスは単なるDTOといった感じで、特に複雑なことはしていない。

次にgetKeyForCipherKeyDecryptionメソッドを呼び出す。
名前からすると「暗号化キーを復号化するための鍵」を取得することが予想できる。
このメソッド内ではCryptoServiceクラスのgetUserKeyWithLegacySupportメソッドを呼び出し、そこではログイン時にメモリ上に保存したユーザーキーを返している。そのため、暗号化キーなるものの復号(そして暗号化)にはユーザーキーを使うことが分かる。
なお、アカウントが組織に属する場合は使う鍵が変わる場合がありそうだが、そこまでは読み込んでいない。

CipherServiceクラスに戻って、encryptCipherWithCipherKeyメソッドに進む。
このメソッドの引数にはこれまで生成したCipherViewCipher、そしてkeyForCipherKeyEncryptionkeyForCipherKeyDecryptionを指定する。
後者2つは名前からすると「暗号化キー暗号化用の鍵」「暗号化キー復号用の鍵」を指定するものと思われるが、そのどちらについてもgetKeyForCipherKeyDecryptionで取得したユーザーキーを渡している。

メソッド内では、まず平文の暗号化キーをCryptoServiceクラスのmakeCipherKeyメソッドで作る。
このメソッドはKeyGenerationServiceクラスのcreateKeyメソッドで鍵を作っているので、つまりこれはアカウント作成時のユーザーキーと同じ処理となっている。

暗号化キーを得たら、それをkeyForCipherKeyEncryption、つまりユーザーキーで暗号化する。
暗号化にはEncryptServiceImplementationクラスのencryptメソッドを使う。これも、アカウント作成時にユーザーキーを暗号化する際に使用したメソッドと同様である。
暗号化済み暗号化キー(ややこしい)はCipherクラスのインスタンスのプロパティにセットされる。

ここまで進んだら、次にCipherServiceクラスのencryptCipherメソッドに進む。このメソッドの引数はCipherViewCipher、そして平文の暗号化キーである。
このメソッド内ではさらにいくつかのメソッドを呼ぶが、そのほとんどが最終的にencryptObjPropertyメソッドを呼び出し、暗号化キーで暗号化した値をCipherにセットする。
暗号化にはやはりEncryptServiceImplementationクラスのencryptメソッドを使う。

ここまで進むと各種機密情報が暗号化された状態のCipherが手に入るので、これを戻り値としてCipherServiceクラスのencryptメソッドは処理を返す。

AddEditComponentクラスのsubmitメソッドに戻るが、その後すぐにsaveCipherメソッド内でCipherServiceクラスのcreateWithServerメソッドを呼び出す。
このメソッド内でCipherからリクエストボディを構成し、リクエストを送信してクライアント側での主要処理は完了となる。

サーバー側

例によってサーバー側の実装はシンプルである。
恐らく、送られてきたアイテムを適当にDBに保存する程度の処理しかないように見える。
(サーバーでは各種鍵の平文は入手できないので、そのようになるのは妥当に感じる)

まとめ

まとめると、

  • アイテム毎の機密情報は暗号化されてサーバーに送信されている
  • 暗号化にはアイテム毎に異なる暗号化キーが使われる
  • 暗号化キー自体もユーザーキーによって暗号化され、アイテムに含まれて送信される

となる。アカウント作成処理の通り、ユーザーキーはマスターキーで暗号化されているため、アイテムが保存されているDBに不正な手段でアクセスできたとしても、その平文を得ることはできない。

murakmiimurakmii

アイテムの取得処理を読む

保存処理が分かっていれば取得処理も自明な気がするが、一応読む。

サーバーに保存していたアイテムの取得は、VaultComponentクラス中SyncServiceクラスのfullSyncメソッドを呼び出すことで行われる(ここ以外でも、いくつかfullSyncメソッドを呼んでいる箇所はある)

このメソッドは内部でリクエストGET /api/syncを送信し、アイテム保存時のレスポンスを含む大部分のデータを取得した後、syncCipherメソッドCipherServiceクラスのreplaceメソッドを呼び出す。
このメソッドは、単にメモリ上に暗号化された状態のアイテムをセットしているだけのように見える。

VaultComponentクラスに戻って、しばらくするとCipherServiceクラスのgetAllDecryptedメソッドが呼ばれる。
このメソッドではユーザーキーでアイテムを平文に戻し、メモリ上にセットする。
また、処理の過程でSearchServiceクラスのindexCiphersメソッドを呼び出し、検索できるようインデックスを構築しているようである。ライブラリとしてlunrを使用している。

murakmiimurakmii

クラウドストレージ MEGA との設計比較

ここまで実装を読んで、パスワードマネージャーのE2EEは結局クラウドストレージのE2EEと同じなのではないかと思い至った。そこでE2EEを謳うクラウドストレージ MEGAと設計を比較してみることにした。ソースコードを公開している気もしたが、ありがたいことにセキュリティに関する資料が公開されているため、こちらより比較させていただくことにする。

アカウント作成処理

「3.1 Registration process」より。
なお用語をアクロニムで略しているが、元の資料ではそのような表記ではない。

  • CSPRNGより得たランダムな128ビットをマスターキーとする
  • 同じくCSPRNGより得たランダムな128ビットをCRV(Client Random Value)とする
  • CRVと固定の文字列を結合し、それを元に計算されたSHA-256をソルトとする
  • ユーザーが入力したパスワードと上記ソルトよりPBKDF2を計算し、DK(Derived Key)とする
  • DKのうち半分をDEK(Derived Encryption Key)とする
  • DEKを鍵としてマスターキーを暗号化した結果をEMK(Encrypted Master Key)とする
  • DKの残りの半分をDAK(Derived Authentication Key)とする
  • DAKを元に計算されたSHA-256をHAK(Hashed Authentication Key)とする
  • 次の内容をサーバーに送信して完了(実際には確認メールの送信等がもう少しある)
    • ユーザーの名前やメールアドレス等
    • CRV。実質的にはソルト
    • EMK。暗号化されているので元のマスターキーはサーバー側からはわからない
    • HAK。ログイン時に使う

Bitwardenと比較するとパラメータや各種関数に細かい違いはあるが、基本的な設計はほぼ同じであることがわかる。パスワードから用途に応じた鍵を派生・生成し、サーバーに送る場合は暗号化して保存する。暗号化に使った鍵は送らないため、サーバー側からはわからない。
MEGAのマスターキーはBitwardenのユーザーキーに相当している。

ログイン処理

「3.2 Login process」より。

  • クライアントは、まずメールアドレスで対応するソルトを問い合わせる。ソルトはCRVと固定の文字列を結合し、SHA-256を計算すれば得られるので、サーバー側でも生成できる
  • 得られたソルトとパスワードを使い、アカウント作成時と同じDKを計算する
  • アカウント作成時と同様に、DKからDEKDAKを得る
  • アカウント作成時と同様に、DAKからHAKを得る
  • クライアントはHAKとメールアドレスをサーバーに送信する。サーバーは、認証しようとしているユーザーのそれと送られたきたものが正しいならログイン成功とし(HAKはアカウント作成時に保存している)、EMKを返す
  • クライアントはEMKをDEKで復号し、マスターキーを得る

Bitwardenのマスターキーと同様、パスワードとメールアドレスがあれば、どのデバイスでもDKを復元できる。あとはHAKで認証し、EMKをDEKで復号すれば、やはりどのデバイスでもマスターキーを得ることができる。

ファイルの暗号化

「4.1 File upload encryption」より。
あまりメモすることはなくて、ファイルごとにランダムなFK(File Key)を作り、それでファイルを暗号化、FKはマスターキーで暗号化して両方をサーバーに保存という流れになる(実際には、FKは暗号化前にファイル内容に基づく値とXORするっぽい)
サーバーはFKがわからないのでファイル内容が読めないが、クライアントならマスターキーでFKを復元できるのでファイル内容が読める、という流れ。
これもアイテム毎に暗号化キーを生成するBitwardenとよく似ている。

というわけで両者の設定は結構似ていることがわかった。
1Passwordもセキュリティ資料を公開しているっぽいので、余裕があったら確認してみたい。

murakmiimurakmii

緊急アクセスを読む

自分のアカウントの情報を他人に読み取らせることを許可する緊急アクセスについて読む。
なお、この機能は実際にテストするのが難しかったので、操作マニュアルを見つつ実装と照らし合わせる。

緊急アクセスは、

  1. 招待
  2. 同意(被招待者が実施)
  3. 確認
  4. 緊急アクセスの使用

の4ステップからなり、このうち暗号化等を行うのは最後の2ステップとなる。

招待

招待の実装はEmergencyAccessServiceクラスのinviteメソッドになる。ここで被招待者のメールアドレス等をパラメータにAPIを呼び出す。
サーバー側は受け取った情報をEmergencyAccessテーブルの1行として保存する。緊急アクセスのステップの進行に伴い、このテーブルの該当行が更新されていく。

受諾

被招待側には招待された旨のメールが送信され、そこには受諾するかどうかのリンクがある。
受諾するとサーバー側の実装が呼び出され、EmergencyAccessテーブルの対応する1行の状態をInvitedからAccepted状態に進める。なお、レコードが取り得る状態は列挙型を見ればわかる。大体、緊急アクセスのステップに対応する値が定義されている。

確認

実際に緊急アクセスを使用する前の準備段階の最後のステップにあたる。
Accepted状態の招待がある場合に、招待側が画面から最終確認ボタンを押下する。
するとEmergencyAccessServiceクラスのconfirmメソッドが呼び出される。

このメソッド中では、まずAPIを呼び出して被招待者が持つ公開鍵を取得する。
この公開鍵は、全ユーザーがアカウント作成時keys.publicKeykeys.encryptedPrivateKeyとして登録した際の公開鍵で、公開鍵は当然平文で保管されているのでサーバーから取得できる。

その後、公開鍵を使って招待側のユーザーキーを暗号化する。この際はWebCryptoFunctionServiceクラスのrsaEncryptメソッドを呼び出す。内部ではWeb Crypto APIのencryptを使う。
このように暗号化されたユーザーキーは本来の所有者本人にもサーバー運用者からも平文は見えず、秘密鍵を平文で所有し得る被招待者側だけが確認できる。

最後に暗号化したユーザーキーをサーバーに送信する。サーバー側では送られてきた暗号化ユーザーキーをEmergencyAccessテーブルの対応する1行に保存し、状態をConfirmedに進めて確認完了となる。

緊急アクセスの使用

Confirmed状態となった緊急アクセスに対して被招待者側は緊急アクセスを行使できる。
実装としてはEmergencyAccessServiceクラスのgetViewOnlyCiphersメソッドがそれにあたる。
行使すると、被招待者側は確認ステップ時に招待者側がサーバーに送信した暗号化済みユーザーキーをAPIから取得する。これは被招待者側の公開鍵で暗号化されているため、被招待者側であれば所持する平文の秘密鍵で復号できる。

復号にはWebCryptoFunctionServiceクラスのrsaDecryptoメソッドを使う。内部ではWeb Crypto APIのdecryptoが使われている。

これにより招待側の平文のユーザーキーが手に入るので、あとは好きに復号して機密情報を確認という感じ。

なお、マニュアルでは緊急アクセス行使時には招待側にリクエストを送るものとなっているが、技術的には3.確認ステップが完了した時点で被招待者側は招待側のユーザーキーを(手に入るなら)復号できる。
それは問題ないのかと思ったが、マニュアルには

設定された待ち時間が経過した後、または許可者が手動で緊急アクセスリクエストを承認すると

とあるため、一定時間が経過すれば承認がなくても見れてよいという設計なのだと思う。
緊急アクセスが必要な状態では緊急アクセスリクエストも見えないという状況は当然考えられるので、この仕様はまぁ妥当かなという感じ。

まとめると、緊急アクセスを認めるのはユーザーキーの平文を渡すのとほぼ同義なので、信頼するユーザーは慎重に決めましょうということ。

murakmiimurakmii

読みたい箇所は大体読んだのでclose。もしかしたら組織機能を追加で読むかも

このスクラップは14日前にクローズされました