0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「知らんサーバーにパスワードを預けたくない」から始めた家族向けE2EE設計

0
Posted at

こんにちは、luthです🐢

PoohMa(プーマ)という、家族でカンタンにアカウント情報を共有できるWebアプリを作っています。

アプリリンク

*アプリは開発中のものですので、本記事の設計内容を含め、正式リリース時には変更される場合もあります。

GitHubリポジトリ


動画配信サービスやWi-Fi、各種サブスクなど、家族で共有しているアカウントは意外と多いのではないでしょうか。
でも実際のパスワードをそのままLINEなどで教え合うのは何だかセキュリティ的に怖いし、見づらいし、見失いやすい。
また、共有メモだと、毎回URLを送りあう必要があったり、管理がバラバラになったりしがちですよね。

PoohMaは、そうした日常のプチストレスを解消するために作りました。
事前にグループを作成しておけば、登録したアカウント情報をワンタップで家族メンバー全員と共有 できます。

ただし、PoohMaは1PasswordやBitwardenのような「パスワード本体を管理し、自動入力するパスワードマネージャー」ではありません。
実際のパスワードではなく、そのヒントを共有保存 、そして 保存するヒント自体もE2EEで暗号化する という、家族の手がかり共有に特化したアプリです。

パスワードマネージャー 共有メモ PoohMa
パスワード本体 保存する 保存できてしまう 保存しない
家族共有 製品による 毎回リンク共有など 事前グループでワンタップ共有
E2EE 製品による 製品による している
主な用途 個人の認証情報管理 情報共有全般 家族で使う認証情報のヒント共有

ここで、ひとつの疑問が浮かぶかもしれません。

「そこまで高度な暗号化(E2EE)を組むなら、パスワードそのものを暗号化して保存すればいいのでは?」
逆に、 「たかが『ヒント』なら、わざわざE2EEで暗号化する意味はあるのか?」

これには、個人開発としての割り切りと、家族向けならではの明確な理由があります。

  1. なぜパスワード本体ではなく「ヒント」なのか?
    家族共用のアカウントなら、手がかり(「いつもの犬の名前+記念日」など)さえ共有できれば、「ああ、あの組み合わせね!」と十分思い出せるパスワードで設定されることが多いと考えています。
    なので、わざわざパスワードそのものを保存する必要はありませんし、何より 「見知らぬ個人開発のWebアプリに、本物のパスワードなんて預けたくない」 と思うのは当然です
    (作る側としても、他人の本物のパスワードなんて怖くて預かりたくありません笑)
    最初からヒントしか扱わない設計にすることで、サービス側にパスワード本体を預けるリスクそのものを無くせます。

  2. じゃあなぜ、その「ヒント」までE2EEで暗号化するのか?
    「ヒント程度なら平文でサーバーに置いてもいいのでは」と思いがちですが、家族によっては、家族がつけるヒントは「ペットの名前」「思い出の旅行先」など、関係性を知っている人なら推測しやすい情報になりがちです。
    もしサーバー侵害やDB流出でヒントが漏洩すれば、パスワード特定のための格好の手がかりを与えてしまいます。
    だからこそ、 「家族以外の第三者(サーバー管理者含む)には一切見せず、家族間だけで閉じる」 ために、ヒント自体のE2EE暗号化が不可欠でした。

  • パスワード本体を保存しない = 万が一サーバーが侵害されても、本丸のパスワードは漏れない
  • ヒントをE2EEで暗号化する = 家族だけの文脈である手がかりすら、サーバーにも第三者にも覗かせない


スマホ上で家族の合言葉を使ってヒントを暗号化し、クラウドには暗号文のみを預けるイメージ

この「二重の安全弁」を成立させることが、PoohMaの暗号設計のスタート地点です。


この記事は、その中核であるE2EE部分を実際に動くプロダクトへ落とし込んだときの設計記録です。
コードはapps/web/src/lib/crypto.tsなどとして公開しているので、気になる箇所があれば覗いてみてください!

鍵の流れはこうです。

通常利用の実線が本質的な経路です。
家族パスコードを手入力すれば、それだけでアンロックは完結します。

上の破線のWebAuthn PRFは日常的な入力をスキップするためのショートカット、右側のリカバリーキットはパスコード紛失時の緊急復元経路です。
生体認証を登録しなくても、基本機能はすべてそのまま利用できます。

この記事では、この構成にたどり着くまでに何を決めていったのか、実際の設計ポイントを書いていきます!


1. 何を守り、何を守らないかを決めた

E2EEをやると決めても、最初に暗号アルゴリズムを選ぶわけではありません。
要件定義書や設計書は実装の前に(ざっくりと)用意していましたが、正直に言えば、脅威モデル(threat-model.md)はそこまできれいな順番では生まれていませんでした。

実装を進めるうちに、「これはE2EEで守っている範囲なのか、そうでないのか」を自分でも言い切れない場面が出てきて、一度立ち止まって書き出したのが.docs/security/threat-model.mdです。
最初から隙のない脅威モデルが完成していたわけではなく、実装を進めながら足元を確かめるために明文化していった、というのが正直なところです。

攻撃者 想定シナリオ 防御
サーバー側の攻撃者 DBダンプの取得、内部不正 E2EE
オフライン総当たり攻撃者 暗号文とソルトから家族パスコードを推測 PBKDF2(30万回)
XSS攻撃者 ブラウザ上のJS実行環境を奪う E2EEでは防げない(厳格なCSP・サニタイズ等による多層緩和)
端末を直接触れる攻撃者 ロック解除済み端末の操作 オートロック(時間的な緩和のみ)
家族メンバー 共有レコードの誤編集・悪用 E2EEでは防げない(アプリ認可の範囲:一般メンバーの編集制限・管理者分離)
アカウント間の越境 別アカウント・別家族のデータへの混入 アカウントID単位のRLS、切替時の即時鍵破棄

信頼できる領域は3層あります。
頭の中 (家族パスコードのみ、システムは制御できない)、 ブラウザ (展開済みマスターキーや表示中の平文ヒントが一時的に存在する)、 サーバー (暗号化済みデータと平文メタデータが永続化される)です。

最後の「アカウント間の越境」は、あとからマルチアカウント対応を入れたときに脅威モデルへ追加したものです。
1つのFirebaseログインで複数のPoohMaアカウント・家族を切り替えられるようにした結果、IDORや鍵の混用という新しいリスクが生まれたので、そのまま書き足しました。

E2EEは「何でも安全にする魔法」ではなく、「サーバーを過度に信頼しなくてもよい範囲を広げる仕組み」だと考えています。
ここをあらかじめ割り切っておかないと、後から設計で苦しむことになりますね…


2. 暗号化する範囲を決めた

パスワードヒントは当然暗号化したい。
でもサービス名・URL・タグ・メモ・ログインIDまで全部暗号化すると、検索や一覧表示がまともにできなくなります。

そこで、暗号化するのは パスワードヒント と、後述する ヒントを復号するためのDEK だけ、と決めました。

暗号化する
└─ パスワードヒント(credentials[].passwordHint)とその復号用DEK

暗号化しない
└─ サービス名・URL・メモ・タグ・ログインID・家族構成

ログインIDすら平文でサーバーに保存する、というのは実装して初めて意識した割り切りでした。
「E2EEだから全部暗号化する」ではなく、「何を秘密として扱うか」を先に決める。
この順番を間違えると、あとで一覧表示の実装に苦しむことになります…!


サービス名・タグ・ログインIDを平文で管理することで、五十音順のインデックスジャンプや一覧表示をサクサク動かせる


3. パスコードとデータ暗号化を分離する

最初に思いつくのは「家族パスコード → PBKDF2 → 暗号鍵 → パスワードヒント」という単純な構成ですよね。
しかしこれだと、パスコードを変更するたびに全データを再暗号化する大きな負荷が発生してしまいます。

そこでEnvelope Encryption(エンベロープ暗号化)にして、パスコードとデータの間にマスターキーとDEK(認証情報ごとの個別鍵)を挟みました。

const PBKDF2_ITERATIONS = 300000; // SHA-256, AES-GCM 256bit

export async function deriveKeyFromPasscode(passcode, salt) {
  const passwordKey = await crypto.subtle.importKey(
    "raw", textToBuffer(passcode), "PBKDF2", false, ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt: textToBuffer(salt), iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
    passwordKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["wrapKey", "unwrapKey"],
  );
}

鍵導出にArgon2やscryptではなくPBKDF2を選んだのは、Web Crypto APIのSubtleCryptoに標準搭載されていて、追加のWASMライブラリなしで完結するからです。
暗号化はブラウザだけで完結させたい、というE2EE要件と相性が良い選択でした。

マスターキーはfamilies.masterKeyEncrypted、DEKはcredentials[].passwordHintDekEncryptedとしてDBに保存します。
で、「レコードごとにDEKを持ったら結局N件処理するのでは」という疑問はもっともで、一覧の復号自体はO(N)のままです。

DEKの役割は復号回数を減らすことではなく、 データの暗号化と鍵の管理を切り離すこと にあります。
マスターキーを変えるとき、DEK方式なら旧マスターキーでDEKをunwrapし、新マスターキーで再wrapするだけで済み、パスワードヒント本体には触りません。

また、この構造にこだわった背景として、「端末に依存しないE2EEモデルにしたい」という意図もありました。
マスターキーは端末固有の鍵ではなく、家族パスコードから都度再構成します。
そのため、スマホからでもPCからでも同じ手順でアンロックできます。
端末に秘密鍵を縛り付けず、家族の頭の中にあるパスコードを共通の入り口としたのは、家族みんなが普段使っている様々なデバイスから、デバイスごとの導入障壁を限りなく無くし、ワンタップ共有された情報をストレスなく閲覧できるようにしたかったからです。


4. 家族変更は鍵の所有権だけを動かす

PoohMaは家族単位で秘密を共有するので、ユーザーが家族グループAからBへ移るケースがあります。

DEK方式のおかげで、やることは「対象レコードのDEKを旧マスターキーでunwrapし、新マスターキーでwrapし直す」だけです。
パスワードヒント本体は再暗号化しません。

const newCredentials = record.credentials.map((cred) => {
  const update = credUpdates.get(cred.id);
  return update
    ? { ...cred, passwordHintDekEncrypted: update.passwordHintDekEncrypted, passwordHintDekIv: update.passwordHintDekIv }
    : cred;
});
await ctx.db.patch(record._id, { familyId: migration.targetFamilyId, credentials: newCredentials, updatedAt: Date.now() });

バッチ分割やレジューム機構はあえて入れていません。
Convexのmutationはトランザクショナルなので、途中で1件失敗すれば全体がロールバックされ、「一部だけ新しい鍵、一部だけ古い鍵」という中途半端な状態にはならないのが助かるポイントです。

PREPARED --30分で自動失効--> EXPIRED
PREPARED --commit成功--> COMPLETED
PREPARED --明示的に中断--> ABORTED

PREPAREDは作成から30分で自動失効し、Cronで定期的に掃除しています。
今のレコード規模ならこれで十分ですが、数千件規模になったら設計し直すつもりです。

なお、家族内での権限管理もシンプルに整理しています。
家族ロールには「管理者(admin)」と「一般メンバー(viewer)」があり、さらに共有レコードごとに個別の管理者(admins)を設定できるDrive型の権限モデルにしています。

一般メンバーは共有されたレコードの閲覧やヒントの復号はできますが、 明示的に管理者権限を持たない限り、共有レコードの編集・更新・削除・共有解除や、家族設定の変更はできない 仕様にしています。
こうすることで、子供やITに不慣れな家族メンバーと共有する場合でも、「誤ってアカウント情報を書き換えてしまった」「うっかり設定をいじってしまった」というトラブルを防げるようにしています。


メンバー管理画面。管理者と一般メンバーを明示的に分けることで、誤操作や不用意な設定変更を防ぐ


5. WebAuthn PRFで「毎回パスコード入力」をなくす

E2EEにしたので、ユーザーが秘密情報を復号するにはマスターキーが必要です。
そのたびに家族パスコードを入力してPBKDF2でunwrapする、という流れはさすがに面倒ですよね。
スマホでFace IDが使えるのに毎回パスコードを打たされるのは、家族向けアプリとしては致命的です。
そこでWebAuthnのPRF拡張を使いました。

ここでのPRFの役割は認証の置き換えではなく、「家族パスコードを毎回入力しなくても、ローカルで安全に再利用できるようにすること」です。
対応判定は単純にisUserVerifyingPlatformAuthenticatorAvailable()を呼ぶだけ、登録はPRF拡張つきでcredentials.create()を実行し、返ってきたPRF出力をAES-GCM鍵としてインポートして、家族パスコードそのものを暗号化します。

const prfSeed = credential.getClientExtensionResults().prf?.results?.first;
const encKey = await crypto.subtle.importKey("raw", prfSeed, { name: "AES-GCM" }, false, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, encKey, new TextEncoder().encode(passcode));
// { credentialId, encrypted, iv, prfSalt } を idb-keyval でユーザーIDをキーにIndexedDBへ保存

なぜ家族パスコードをそのままIndexedDBへ保存しないのか、という点が肝です。
平文で保存すればXSSで取られるリスクがあります。
「パスコードを保存しないから安全」ではなく、「保存する必要がある値を、PRFをルートにして暗号化して保存する」という考え方です。

登録の導線も専用画面を作らず、パスコード入力ダイアログに「次回から指紋/FaceIDでロック解除する」というチェックボックスを埋め込みました。
生体認証を登録しても、パスコード入力欄自体は消えません。
あくまで隣にもう一つ選択肢が増えるだけです。


6. 無操作オートロックと、失敗時のバックオフ

マスターキーはReactのstateとrefにしか存在させず、どこにも永続化しません。
ログアウトや家族切り替え、アカウント切り替えの瞬間にその場で破棄しますし、無操作が5分続くと自動でロックもかかります(タブを閉じていた時間もカウント対象で、無効化もできます)。

アンロックのUIはrequireUnlock()というPromiseを返す共通関数に統一し、アプリのどこからでも「ロック中なら解除ダイアログを出し、解除できたらtrueで返す」という形にしています。

パスコード自体の強度チェックとオンラインの試行制限も入れています。
パスコード作成時にzxcvbn-tsでスコアを判定し、最低10文字かつスコア2以上を要求します。
unlock時の連続失敗には指数バックオフをかけていて、3回目の失敗から待ち時間が発生し、以降は2倍ずつ延びて最大30秒でロックアウトします。

const delayMs = Math.min(2 ** (nextAttempts - 3) * 1000, 30000);

ただし、これはあくまでアプリのUI経由でunlockを試みた場合の制限です。
もし暗号文とソルトそのものが流出してしまえば、攻撃者はこのUIを経由せずオフラインで総当たりできます。
そちらへの防御は、あくまでPBKDF2の30万回という計算コストだけが頼りです。
「バックオフを入れたから万全」というわけではなく、UI経由のオンライン攻撃はバックオフで遅延させ、DB流出時のオフライン総当たりはPBKDF2の計算コストで耐える、というように想定する攻撃経路ごとに守るレイヤーを分けています。


7. パスコードを忘れたときのための「リカバリーキット」

E2EEを採用した以上、「パスコードを忘れたらサーバー管理者が平文に戻してリセット」というバックドアは作れません。
それをやってしまうと、「サーバーは秘密を知らない」というE2EEの根本原則(Zero-Knowledge)が崩れてしまうからです。

かといって、「パスコードを忘れたらデータは二度と戻りません、諦めてください」では家族向けアプリとしてツラいですよね。

そこでPoohMaでは、 「リカバリーキット(復元コード)」 を実装しました。

仕組みとしては、以下の2要素を組み合わせたリカバリー経路を用意しています。

  1. 発行時に1度だけ表示されるリカバリーコード (高エントロピーな文字列、サーバーには一切保存しない)
  2. 登録メールアドレス宛に届く6桁のEmail OTP (ハッシュ保存、10分有効)

パスコード経路とは別に、リカバリーコード由来のHKDF-SHA256鍵で同一のマスターキーへ到達する第2のラップ経路(recoveryMasterKeyEncrypted)を作っています。

「秘密の質問」は推測されやすくE2EEの意味を失うため不採用にしましたし、「Email OTP単体」もメールアカウント乗っ取りで一発突破されてしまうため不採用としました。
「物理的な復元コード × Email OTP」の2要素にすることで、E2EEの堅牢さを保ちながら、万が一のロックアウトに備えられるようにしています。


PoohMaのリカバリーキット。復元コードとメールOTPを組み合わせることで、サーバー管理者のバックドアを一切作らずに安全な緊急復元を可能にしている


8. 今のスコープと、これからの挑戦

現在の実装状況を整理すると、以下のようになっています。

項目 現状
E2EE(パスワードヒントのみ暗号化) 実装済み
Envelope Encryption(マスターキー+DEK) 実装済み
DEK再wrapによる家族変更 実装済み(トランザクション保護)
家族内ロール・権限分離(一般メンバーは閲覧のみ、編集・設定は管理者に限定) 実装済み
WebAuthn PRF+無操作オートロック 実装済み
パスコード強度チェック(zxcvbn-ts) 実装済み
失敗時の指数バックオフ・一時ロックアウト 実装済み
マルチアカウント間の境界分離(RLS・即時鍵破棄) 実装済み
リカバリーキット(高エントロピー復元コード+Email OTPの2要素)による復旧 実装済み
厳格なCSP(nonceベース+セキュリティヘッダー)の導入 実装済み
PBKDF2反復回数のスキーマ管理・後方互換性(families.kdfIterations) 実装済み

次に見据えているロードマップ

PoohMaのコアである「家族向けE2EE」の土台は整いましたが、GitHubの未解決Issueには、まだまだ挑戦したい課題がたくさんあります。
特に以下のセキュリティ・アーキテクチャのテーマは、今後じっくり取り組んでいきたいポイントです。

  1. OPAQUEプロトコル(RFC 9706)の導入(Issue #273)
    現状のオフライン総当たり対策はPBKDF2の計算コスト頼りですが、非対称パスワード認証鍵交換(aPAKE)プロトコルである OPAQUE を導入することで、万が一暗号化マスターキーとソルトが流出した場合でも、オフライン辞書攻撃を原理的に無効化することを目指しています。
  2. メタデータのE2EE暗号化および秘匿検索(Searchable Encryption)(Issue #274)
    現在は一覧性や検索性のためにサービス名やログインIDを平文管理していますが、検索可能暗号(Searchable Encryption)を導入し、サーバー側に一切平文を預けずにクライアント側で検索できる完全秘匿化の実験を進めています。
  3. Web Worker + IndexedDBによる暗号化の中断・再開アーキテクチャ(Issue #111)
    家族変更時の全レコード再wrap処理において、メインスレッドのUIブロッキングを防ぎ、巨大なデータでも安全にバックグラウンドで中断・再開できるパイプラインを設計中です。
  4. いざという時の家族連携機能(デジタル遺品・緊急時開示)(Issue #17)
    普段は個人用(user)にしているアカウントを、万が一の病気や逝去時にのみ、一定の承認プロセスと本人通知を経て家族が復元・引き継ぎできる「家族向けならではのセーフティネット」の検討も進めています。

9. 割り切ったこと・あえてE2EEで守っていない範囲

最後に、PoohMaがどこまでをE2EEでカバーし、どこを割り切っているのか、その境界線を整理しておきます。

実際のパスワードそのものはそもそも入力・保存する機能がなく、サービス名・URL・メモ・タグ・ログインID・家族構成は平文管理です。

XSS対策としては、サーバーミドルウェアによるnonceベースの厳格なCSP(default-src 'none', script-src 'strict-dynamic' ...)や各種セキュリティヘッダーを導入し、Reactのエスケープと合わせて多層防御を敷いています。
ただし、万が一ブラウザ上で任意のJavaScript実行を許してしまった場合や、悪意あるブラウザ拡張機能・端末マルウェア等により「クライアントの実行環境そのもの」を侵害された場合は、展開済みの鍵や平文ヒントを窃取され得ます。
E2EEはサーバー側の侵害からは守ってくれますが、クライアント環境自体の侵害までは防げません。

また、家族グループ内の共有レコードについては、一般メンバーによる誤編集や設定変更は権限分離で防いでいるものの、「閲覧・ヒントの復号」自体は家族全員に開いています。
共有した時点で、そのレコードは家族の誰か一人にでも見られうる、という前提は変わりません。

暗号文とソルトが流出した場合のオフライン総当たり攻撃に対しては、UIのバックオフは効かないため、PBKDF2の計算コスト(30万回)とパスコード自体の強度要件が防御壁となります。

E2EEが担うのは、あくまで「サーバーやDBの管理者を信用しなくても、保存された秘密情報を読まれないようにする」という境界線です。


まとめ

暗号化を強くするほどUXは悪くなります。
UXを優先すると、結局どこかで秘密をサーバーに預けることになります。
PoohMaでは、その間をEnvelope EncryptionとWebAuthn PRF、そしてリカバリーキットで埋めようとしました。

問いを言い換えると、こうなります。
「どう暗号化するか」ではなく、「どうすれば秘密をサーバーに預けず、それでも普通の家族向けサービスとして使えるのか」。

今回それを、パスコード・マスターキー・DEKの責務分離、家族変更時のDEK再wrap、日常を支えるWebAuthn PRF、そして緊急時のリカバリーキットという組み合わせで解こうとしました。

事前にグループになった家族とワンタップでアカウント情報を共有できる手軽さ と、 「知らんサーバー」にパスワードもヒントの平文も預けない安心感。
この相反しがちな要求に対して、現時点で心地よい着地点を探った結果が今回の設計です。

同じように家族単位・チーム単位で秘密を共有するプロダクトを設計している方の参考になれば嬉しいです!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?