Lintoの技術解説
このページでは、Lintoで使っている開発言語・技術スタック・システム構成と、特に力を入れて設計した エンドツーエンド暗号化まわりのセキュリティ設計を、他の開発者の方の参考になるよう詳しく解説します。 個人開発アプリの実装例として、設計判断の理由や踏んだ制約も含めて公開できる範囲で記載しています。
開発言語・技術スタック
フロントエンド(Android版・Web版)は単一の React Native / Expo コードベースで、プラットフォーム差分は
.web.ts / .web.tsx というファイル拡張子の慣習(React Nativeの
プラットフォーム拡張)だけで吸収しています。バックエンドは自前サーバーを持たず、Firebase(BaaS)と
GCPのサーバーレス基盤のみで構成しています。
| 領域 | 採用技術 |
|---|---|
| 言語 | TypeScript ~6.0 |
| UIフレームワーク | React 19.2 / React Native 0.86 / react-native-web ~0.21 |
| アプリ基盤 | Expo SDK 57(Managed Workflow) |
| ルーティング | expo-router ~57(ファイルベースルーティング、Typed Routes有効) |
| アニメーション | react-native-reanimated 4.5 / react-native-worklets 0.10 |
| 状態管理 | Redux等は未使用。useSyncExternalStoreベースの自前Store(後述) |
| 最適化 | React Compiler(experiments.reactCompiler)。手動useMemo/useCallbackは書かない方針 |
| 暗号化(クロスプラットフォーム) | @noble/ciphers・@noble/curves・@noble/hashes(pure TypeScript実装) |
| 暗号化(ネイティブ高速化) | react-native-quick-crypto(Nitro Modules経由のネイティブOpenSSL、Android/iOSのみ) |
| ローカル永続化 | @react-native-async-storage/async-storage |
| 認証・データベース | Firebase Authentication(Googleサインイン)/ Cloud Firestore(firebase JS SDK ^12) |
| サーバーレス関数 | Cloud Functions for Firebase(2nd gen, Node.js 22) |
| バッチ処理基盤 | GCP Cloud Run Jobs + Cloud Scheduler(独立したnpmプロジェクト、Node.js) |
| 生成AI | Vertex AI Gemini API(@google/genai) |
| ホスティング | Firebase Hosting(マルチサイト構成、GitHub Actionsで自動デプロイ) |
| CI | GitHub Actions(Web版・ドキュメントサイトそれぞれ専用ワークフロー) |
| テスト | Jest ~29 / jest-expo / @testing-library/react-native |
| Lint | ESLint 9 / eslint-config-expo |
システム構成
クライアント(Android版・Web版)はFirebase Authentication・Cloud Firestoreと直接通信します。 サーバーサイドのロジックは、AIタグ提案(対話的・低レイテンシが必要)はCloud Functions、 AIリンクの週次収集(バッチ・長時間実行)はCloud Run Jobsと、用途に応じて実行基盤を分けています。 どちらもGeminiの呼び出しにはVertex AIを使い、Firestoreアクセスを含めて静的なAPIキー・秘密鍵を一切持たず、 実行環境にアタッチしたサービスアカウントのApplication Default Credentials(ADC)だけで認証します。
ポイントは「同じFirestoreプロジェクトの中に、暗号化必須のデータ(link/task/tag)と、 サーバー側処理のためにあえて平文のままにしているデータ(AIキーワード・AIリンク・AI性格設定・ 週次メッセージ)が併存している」ことです。この区分と、それぞれの保護方法は セキュリティ設計で詳しく説明します。
なお、AIタグ提案(suggestLinkTags)はAndroid版・Web版のどちらからも呼び出せるため、
図では両方のクライアントからCloud Functionsへ矢印を伸ばしています。一方、「180日以上更新していない
リンクを自動で倉庫へ移動する」機能はGemini等を使わない純粋なクライアント内のルールベース処理
(src/domain/stale-links.ts)で、外部と通信するわけではありません。図の右上の
破線囲みのように、通常のリンク保存(archived: trueへの更新)と同じ経路で
E2E暗号化されたままFirestoreへ書き込まれるだけなので、独立した箱にはせず「クライアント内の処理例」
として注記しています。
技術的に工夫している点
1. プラットフォーム差分を「暗号の中身」まで隠すクロスプラットフォーム設計
Android/iOS版はネイティブOpenSSL実装(react-native-quick-crypto、Nitro Modules経由)、Web版は
ブラウザ標準のWebCrypto(crypto.subtle)と、鍵導出の実装がプラットフォームごとに
まったく異なります。これをderive-from-passphrase.ts / derive-from-passphrase.web.ts
という同一シグネチャの関数として切り出し、呼び出し側(暗号化・復号のロジック)はプラットフォームを
一切意識しません。当初pure-JS実装(@noble/hashesのPBKDF2)だけで統一しようとしましたが、
実機(Pixel 10 Pro XL)で反復回数60万回のPBKDF2に1〜2分かかることが判明し(HermesエンジンにJITが無いため)、
ネイティブ実装への切り替えが必須になりました。結果的に数十ミリ秒まで短縮しています。
2. サーバーが「読めない」設計のE2E暗号化
リンク・タスク・タグの本文はすべてクライアント側でAES-256-GCM暗号化してからFirestoreへ送信します。 復号鍵はユーザーが設定したパスフレーズから導出し、パスフレーズ自体はネットワークに一切送信しません。 Firestoreのデータが仮に漏えいしても、パスフレーズを知らない限り内容を読むことはできません。 詳細はセキュリティ設計を参照してください。
3. ロック解除なしでも共有できる「ブックマークレット即時保存」
Web版はDEK(データ暗号化鍵)をメモリ上にしか保持しないため、タブを閉じるたびにロックされます。
一方でブックマークレットからの共有はロック解除前でも発生しうるため、通常の暗号化フローとは別に、
公開鍵暗号(X25519 + HKDF + AES-256-GCM)だけでURLを安全に一時保存できるpendingLinks
という仕組みを別途用意しています。秘密鍵を必要とせず公開鍵だけで暗号化できるため、ロック中でも
データを失わずに受け取れます。詳細はセキュリティ設計内の解説を参照してください。
4. Firestoreルールの「OR結合」を逆手に取らせない設計
Firestoreセキュリティルールは複数のmatchブロックがOR結合されるため、
「ユーザー自身の書き込みを許可する緩いワイルドカードルール」と「サーバーのみ書き込み可能な
厳格なルール」が両方存在すると、後者をusers/{uid}/配下に置いた場合に前者の緩いルールが
先に一致してしまい、改造クライアントから直接書き込めてしまう抜け道になります。AIタグ提案の
レート制限カウンタ(aiTagUsage / aiTagLimits)はこの理由から意図的に
ルート直下の別コレクションに配置し、allow write: if falseで固めています。
詳しくは後述します。
5. 静的シークレットを一切持たないサーバーレス基盤
Cloud Functions・Cloud Run Jobsのどちらも、FirestoreへのアクセスとGemini(Vertex AI)の呼び出しを
すべて実行環境のサービスアカウントによるApplication Default Credentials(ADC)で行い、
.envやSecret Managerに保存する静的なAPIキー・秘密鍵ファイルを持ちません。
旧構成(Cloudflare Workers版)ではFirebaseサービスアカウントの秘密鍵JSONと静的なGemini APIキーの
2つを管理する必要がありましたが、GCPへ移行したことでこれらの静的シークレットそのものを
削除できました。
セキュリティ設計(詳細)
ここでは、他の開発者の方の参考になるよう、暗号化・鍵管理・アクセス制御の設計をできるだけ具体的に 説明します。まず前提として、このアプリの脅威モデルを明確にしておきます。
守る対象: Firestoreのデータそのものが漏えいした場合でも、リンク・タスク・タグの本文 (URL・タイトル・説明・チェックリスト・タグ名)をパスフレーズなしに読めないようにすること。
守らない対象(既知のトレードオフ): 作成日時・更新日時・完了フラグ等のメタデータは 暗号化していません。また、AIキーワード・AI収集リンク・AI性格設定・週次メッセージは Gemini APIにサーバー側から渡す都合上、意図的に平文で保存しています (個人を強く特定する機密情報ではないキーワード・記事タイトル・要約に限定)。
鍵導出: PBKDF2-HMAC-SHA256、60万回反復
ユーザーが設定するパスフレーズから、PBKDF2-HMAC-SHA256(RFC 8018)で
鍵暗号化鍵(KEK)を導出します。反復回数は600,000回とし、これは
OWASPのPassword Storage Cheat Sheetが2023年に示したPBKDF2-HMAC-SHA256の
推奨値に合わせています。ソルトはユーザーごとにランダム生成し、反復回数とともに
users/{uid}/cipherMaterial/mainに保存します(パスフレーズ自体はどこにも保存・送信しません)。
DEKのラップ/アンラップとキャッシュ方式
実際にフィールドを暗号化するのはKEKではなく、ランダム生成したデータ暗号化鍵(DEK)です。 DEKはKEKでラップ(AES-GCMで暗号化)した状態でFirestoreに保存し、平文のDEK自体は サーバー側に一度も送信しません。プラットフォームごとのDEKキャッシュ方針は次の通りです。
| プラットフォーム | ロック解除後のDEK保持先 | 再入力が必要になるタイミング |
|---|---|---|
| Android / iOS | expo-secure-store(Keychain / EncryptedSharedPreferences) | 基本的に再起動後も保持される |
| Web | メモリ上のみ(永続化しない、意図的な設計) | タブを閉じる・再読み込みするたび |
Web版でメモリ保持のみにしているのは、ブラウザにはOSレベルのSecure Enclave/Keystoreに相当する
安全な永続領域が無く、localStorage等に置くとXSS等で読み取られるリスクが上がるためです。
利便性より安全性を優先したトレードオフとして、意図的にこの設計にしています。
暗号化フィールド一覧
| エンティティ | 暗号化する項目 | 平文のまま保存する項目 |
|---|---|---|
| Link | url, title, description, tags(JSON配列) | archived, createdAt, updatedAt |
| Task | title, description, checklist(JSON), tags(JSON) | id, 日時関連, allDay, manualProgress, completed, calendarReminders, linkedLinkUrls, createdAt, updatedAt |
| 登録済みタグ | name | createdAt, updatedAt |
| AIキーワード | (対象外) | keyword, createdAt |
| AIリンク | (対象外) | url, title, summary, whyRead, keyword, publishedAt, createdAt |
| AI性格設定 / 週次メッセージ | (対象外) | persona, message, updatedAt/createdAt |
| pendingLinks | url, title(X25519公開鍵暗号方式、後述) | ephemeralPublicKey, createdAt |
メタデータ(日時・完了フラグ等)を平文にしているのは、並び替え・絞り込みをFirestoreクエリ側で 行うためです。これらの暗号化にはあまり機密性が無く、暗号化するとクエリが組めなくなる デメリットの方が大きいと判断しました。
メールアドレス等のアカウント情報の保存・管理方針
上の表の対象はいずれもLinto独自のFirestoreドキュメント(users/{uid}/...配下)です。
一方、Googleサインイン時に取得するメールアドレス・表示名はFirestoreドキュメントとしては
一切保存していません。これらはFirebase Authenticationが管理するユーザーレコードそのもので、
クライアントはfirebase/authのSDKが返すUserオブジェクト(サインイン中のみ
メモリ上に保持)から都度読み取り、設定画面での表示にのみ使っています
(src/data/auth/default-auth-repository.tsのtoAuthUser、
src/domain/auth-user.ts)。アプリ側のコードがメールアドレスをFirestoreの
どこかのフィールドへ書き込む処理は存在しません。
Firebase Authenticationのユーザーレコード自体(メールアドレスを含む)は、GoogleのFirebase基盤側で 保存時暗号化を含む標準的なセキュリティ管理下に置かれます。ただし、これはGoogleが提供する認証基盤の 固定スキーマであり、パスフレーズ由来のDEKでフィールド単位に暗号化するLinto独自のE2E暗号化の対象には 含められません(サインイン・アカウント復旧といった認証機能自体がGoogle側でメールアドレスを 照合できる必要があるため、アプリ側で追加暗号化することもできません)。この点はプライバシーポリシーの 「1-1. アカウント情報」にも明記しています。
メールアドレスの二次利用・複製は行わない方針を徹底しています。具体的には、
-
エラーログ(
errorLogs)にはuidのみを記録し、メールアドレスは 含めません。Firestoreルール側でも書き込み可能なフィールドを['uid', 'context', 'message', 'level', 'platform', 'createdAt']に 厳格に制限しており、コード側で誤って追加しようとしてもルール違反で書き込み自体が失敗します (ログに平文を残さない設計も参照)。 -
AIキーワード・AIリンク等、平文で保存している他のコレクションにもメールアドレスは一切含みません。
これらはドキュメントのパス(
users/{uid}/aiKeywords/...等)でユーザーを識別しており、 値そのものにメールアドレスを含める設計にしていません。 -
広告SDK・分析SDK・行動トラッキング用の第三者SDKは組み込んでおらず、メールアドレスを含む
利用者情報を広告・分析目的の第三者へ送信することはありません(
privacy-policy.html「3. 第三者への提供・委託先」「4. 広告・トラッキングについて」参照)。
pendingLinksのハイブリッド公開鍵暗号(X25519 + HKDF + AES-256-GCM)
ブックマークレットや共有シート経由の「クイックキャプチャ」は、Web版がロック解除されていなくても URLを失わずに受け取れる必要があります。そこで通常のDEKベースの暗号化とは別に、公開鍵暗号(ECIES類似の ハイブリッド方式)を使っています。
- DEK確定(パスフレーズ設定)時に、X25519の鍵ペアを1組生成します。公開鍵は平文のまま
cipherMaterial.pendingLinkPublicKeyに、秘密鍵はDEKでラップしてwrappedPendingLinkPrivateKeyとして保存します。 - キャプチャ時(ロック中でも可): 一時的なX25519鍵ペアを都度生成し、ユーザーの公開鍵とECDHで
共有シークレットを計算、HKDF-SHA256で暗号鍵を導出してAES-256-GCMでURL・タイトルを暗号化します。
暗号文と一時公開鍵(ephemeralPublicKey)を
pendingLinksコレクションへ書き込みます。 - 受信時(アプリ起動後、ロック解除済み): DEKでアンラップした自分の秘密鍵と、保存されている
一時公開鍵でECDH→HKDFを再計算して復号し、通常のLinkとして通常の暗号化フローで再保存、
pendingLinksからは削除します(useDrainPendingLinks)。
Firestoreルールの落とし穴: ワイルドカードとOR結合
Firestoreセキュリティルールでは、複数のmatchブロックの条件はORで
評価されます。Lintoのメインルールはユーザー自身の全サブコレクションを許可する広いワイルドカードです。
match /users/{userId}/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
もしAIタグ提案のレート制限カウンタをusers/{uid}/aiTagUsage/...のように
このワイルドカードの配下に置いてしまうと、より厳格なルールを別途書いても、
上記の緩いルールが先に一致するため後から書いた厳格なルールでは上書きできません
(AND結合ではなくOR結合であるため)。改造したクライアントから直接カウンタを書き換えれば
レート制限をすり抜けられてしまいます。そのため、レート制限カウンタは意図的にルート直下の
別コレクションに切り出し、クライアントからの書き込みを完全に禁止しています。
// ルート直下: ワイルドカードの対象外になる
match /aiTagUsage/{docId} {
allow read, write: if false; // Cloud Functionsの Admin SDK はルールを経由しないため書き込み可能
}
match /aiTagLimits/{uid} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
}
レート制限自体は、Cloud Functions側でFirestoreのトランザクションを使って 「本日の呼び出し回数が上限未満なら原子的に+1してtrueを返す」という形で実装し(1ユーザー1日5回)、 読み取りと書き込みの間に競合が起きないようにしています。
Gemini API・各サーバーへのアクセス制御と最小権限設計
Firestore以外のバックエンド(Cloud Functions・Cloud Run Jobs・Vertex AI Gemini API)についても、 「誰が」「どの権限で」呼び出せるかを整理しておきます。実装済みの部分と、まだ対応できていない 部分(今後の課題)を区別して、正直に記載します。
| コンポーネント | 呼び出し元を制限する仕組み | 実行時の権限(IAM) | 状態 |
|---|---|---|---|
| Cloud Firestore | セキュリティルールでrequest.auth.uid一致のみ許可 |
- | 実装済み(前述) |
| suggestLinkTags (Cloud Functions 2nd gen) |
Firebase AuthのIDトークンを関数コード側で検証(request.auth) |
専用サービスアカウント(linto2-functions@linto2.iam.gserviceaccount.com)にroles/aiplatform.user・roles/datastore.userのみ付与 |
実装済み(最小権限) |
| Vertex AI Gemini API(Cloud Functions経由) | クライアントから直接は呼べず、上記Cloud Functionsのサービスアカウント経由のみ | 同上 | 実装済み |
| ai-link-collector (Cloud Run Jobs) |
Cloud Schedulerのみが対象Jobへのrun.invoker権限を持つ(外部から直接execute不可) |
専用サービスアカウント(ai-link-collector@linto2.iam.gserviceaccount.com)にroles/datastore.user・roles/aiplatform.userのみ付与 |
実装済み(最小権限) |
| Firebase Authサインイン自体 | GoogleのOAuthクライアント登録(Android: パッケージ名+署名証明書のSHA-1、Web: 承認済みJavaScriptオリジン) | - | 実装済み |
ネットワーク層とアプリ層のアクセス制御を分けて考える必要がある点が、Cloud Functions
(2nd gen)特有の注意点です。suggestLinkTagsはFirebaseのcallable function
(onCall)として実装していますが、実体はCloud Run上で動作しており、Cloud Run自体の
IAM(呼び出し権限)はallUsersに開放されています。一見「誰でも呼べる」ように見えますが、
これはFirebase callable functionの標準的な構成で、実際の認証はネットワーク層ではなく
関数コードの内部で行われます。クライアントSDK(getFunctions /
httpsCallable)がリクエストにFirebase AuthのIDトークンを自動で添付し、
functions/src/index.ts側でrequest.authの有無を確認、未認証なら
即座にunauthenticatedエラーを返して拒否します。つまり「サインイン済みFirebaseユーザーか」
はコードレベルで厳格にチェックしていますが、「Linto公式アプリからの呼び出しか」までは
現状チェックしていません。
サービスアカウントの権限設計は、ai-link-collector(Cloud Run Jobs)・
functions/(Cloud Functions 2nd gen/1st gen)とも専用のサービスアカウントを
新規作成し、Firestore・Vertex AIに必要な最小限のロール(roles/aiplatform.user・
roles/datastore.user)のみを付与する方針で統一しています(2026-09-26対応。
それ以前はfunctions/側がデプロイ時に指定を省略していたため、他のCloud Run/
Compute系サービスとも共有されうるCompute Engineの既定サービスアカウントで実行されていました)。
-
Firebase App Check(Play Integrity)未導入。
導入できれば、Firestore・Cloud Functionsへのリクエストが「改ざんされていない公式アプリ
インスタンスから」であることまで検証でき、有効なFirebase IDトークンを何らかの方法で
入手された場合の残存リスクを下げられます。pure Firebase JS SDK(Web版で使用)が
Play Integrityをネイティブサポートしておらず、追加SDK(
@react-native-firebase/app-check等)の導入が必要と判明し、想定より実装コストが大きいため優先度を下げて保留しています。 - Firebase APIキーへの「アプリケーションの制限」「APIの制限」未設定。 Google Cloud Console側で、Android用キーをパッケージ名+SHA-1証明書に、Web用キーを HTTPリファラーに制限し、それぞれ実際に使うFirebase系APIのみを許可する設定を対応中です。
これらはいずれも「多層防御の追加レイヤー」であり、現時点でも Firestoreセキュリティルール(uidベースのアクセス制御)・E2E暗号化(サーバーが本文を読めない)・ Cloud FunctionsのFirebase Auth検証・各サービスアカウントの権限スコープという複数の防御は 機能しています。ただし、特定のクライアントアプリ(改ざんされていない公式ビルド)からの 呼び出しであることまでは、App Check導入前の現状では技術的に保証できていません。
Google Play Store初期公開に向けた費用対効果評価
上記項目について、「利便性への影響」「対策効果」「実装コスト」「品質リスク」の4軸で評価し、 Google Play Storeでの初期公開時点で対応すべきかを判断しました。
| 対策 | 利便性への影響 | 対策効果 | 実装コスト | 品質リスク | 初期公開時点での要否 |
|---|---|---|---|---|---|
| Firebase App Check (Play Integrity) |
中〜高 | 中 | 高 | 中〜高 | 見送り推奨 |
| Firebase APIキー制限 (アプリ制限・API制限) |
ほぼ無し | 中 | 低 | 低 | 対応中 |
| Cloud Functions専用 サービスアカウント分離 |
無し | 低〜中 | 低 | 低 | 対応済み(2026-09-26) |
App Checkを初期公開では見送る理由:
-
対策効果が見た目ほど大きくない。
suggestLinkTagsは1ユーザー1日5回のGemini呼び出し上限があり、Firestoreはuidベースの セキュリティルール、リンク・タスク・タグの本文はE2E暗号化済みです。App Checkが無くても 「他人のデータを読まれる」「無制限にコストを浪費される」リスクは既に低く抑えられており、 App Checkが追加で防ぐのは主に自作クライアントによるGemini呼び出しコストの悪用ですが、 これも日次上限で頭打ちになるため実利が小さいと判断しました。 -
実装コストが本当に大きい。
現行は純粋な
firebaseJS SDKの構成のため、Play Integrityのネイティブサポートには@react-native-firebase/app-checkという別系統SDKの追加統合が必要で、単純な 追加インストールでは済みません。加えてWeb版はPlay Integrityが使えず、reCAPTCHA系の 別プロバイダ実装が必要になるため、Android/Web二重の実装・検証コストがかかります。 - 品質リスクが公開直後は特に高い。 Play Integrityの判定精度はインストール実績が少ないアプリでは低下しやすいという 既知の傾向があり、公開直後に強制(Enforce)へ切り替えると正規ユーザーを誤ってブロックする 恐れがあります。「監視のみ→強制」の段階導入をしても判定が安定するにはある程度のインストール 実績が必要で、公開直後というタイミング自体と相性が悪いと考えました。
- Google Play Storeの審査自体はApp Check導入を要求しません。 インストール数が積み上がった後、または実際にGemini呼び出しコストの急増・不審なトラフィックなど 悪用の兆候が観測された段階で再評価する方針です。
APIキー制限はコード変更・再ビルドが不要でGoogle Cloud Console上の設定のみ
のため、費用対効果が良く初期公開前後での対応を進めています(AndroidのSHA-1登録では、
デバッグ鍵・ローカルリリース鍵に加えてPlay App Signingの署名鍵SHA-1の登録漏れに注意が必要です)。
専用サービスアカウント分離はai-link-collectorと同じパターンで
対応済みです(suggestLinkTags・provisionAiTagLimits・
removeAiTagLimitsの3関数をlinto2-functions@linto2.iam.gserviceaccount.comで
実行するようコード側を変更し、サービスアカウントの作成・権限付与・再デプロイの手順を
OPERATIONS.mdに整備しました)。
ログに平文を残さない設計
復号済みの本文(タイトル・説明・URL等)はログに一切出力しません。エラーログ共通窓口
(logError/logWarn)はローカルのconsole出力に加え、保守運用の
問い合わせ対応のためにuid付きでFirestoreのerrorLogsコレクションへも
記録しますが、書き込み可能なフィールドをhasOnly([...])で厳格に許可リスト化し、
context/message文字列にサイズ上限を設け、クライアントからの
読み取り・更新・削除は一切許可していません(createのみ、確認は運用手順書に基づき
コンソール側で行う)。クライアントIPは自己申告させず(詐称可能なため)、必要な場合はGoogle Cloud
Audit LogsのData Accessログ(protoPayload.requestMetadata.callerIp)を利用する方針です。
データ保管・バックアップ・リストア設計と障害対応
ここまでは「漏えい・不正アクセス」への対策(機密性)が中心でしたが、ここでは 「誤削除・障害・災害によるデータ損失」への対策(可用性・完全性)を扱います。 保管設計、バックアップ・リストア設計、そして障害発生時に何をどこで確認するかという ログ運用について、実装済みの部分と、手順は整備済みだが実際にはまだ有効化していない部分を 区別して記載します。
保管設計: データはどこに、どう冗長化されているか
正のデータストアはCloud Firestore(Google管理)です。Firestore Native modeは、 データベース作成時に選んだロケーション(リージョン型/マルチリージョン型)に応じて、 Google側のインフラが自動的に複数ゾーン(リージョン型の場合)または複数リージョン (マルチリージョン型の場合)へ同期レプリケーションします。これは開発者側で追加の構成をせずとも Firestore自体が備える冗長性で、単一データセンター障害でデータが失われることはまずありません。
クライアント側(AsyncStorage)にも復号済みのローカルコピーが存在しますが、これはオフライン 閲覧・表示用のキャッシュであり、永続的なバックアップとしては扱っていません。端末の紛失や アプリの再インストールで失われる前提のデータです。
想定する損失シナリオと現状の対応
| シナリオ | 現状の対応 |
|---|---|
| 誤ってリンク・タスクを削除/上書き編集してしまった | アプリ内にUndo機能は無し。後述のPITR・スケジュールバックアップが有効化されていれば 復旧可能だが、現時点では未有効化(下記参照)。 |
| 運用ミス・スクリプトの不具合等でFirestoreのデータが意図せず失われた | 同上。PITR・スケジュールバックアップの運用手順は整備済みだが未有効化。 |
| 端末を紛失・機種変更した | Googleサインイン+クラウド同期により別端末で復元可能(パスフレーズが分かる場合)。 未サインイン時にローカルのみへ保存したデータは同期対象外のため端末とともに失われる。 |
| 暗号化パスフレーズを忘れた | データ自体はFirestore上に残っていても復号不能。サーバー側に復旧経路を持たない E2E暗号化の設計上、意図した挙動(詳細は後述「E2E暗号化とバックアップの限界」)。 |
| 手元に明示的なバックアップファイルを残しておきたい | 設定画面のインポート・エクスポート機能で手動バックアップ・復元が可能(実装済み、詳細後述)。 |
サーバー側バックアップ・リストア設計(PITR・スケジュールバックアップ)
Firestoreには2種類のバックアップ機構があり、運用手順としてOPERATIONS.mdに
整備済みです。2026年9月26日時点では手順のみ用意されており、実際のgcloud/Google Cloud
Console側での有効化はまだ行っていません(下記の「初期公開に向けた評価」で
有効化を推奨しています)。
-
Point-in-Time Recovery(PITR): 有効化すると、直近7日以内の任意の時点へ
データベース全体を巻き戻せます。誤削除・誤更新からの復旧に有効です。
gcloud firestore databases update --database='(default)' --enable-pitr --project=linto2の1コマンドで有効化できます。 -
スケジュールバックアップ: 日次で自動的にバックアップを作成し、既定7日間
保持する設定です(長期保持が必要な場合は週次90日保持等を別途追加可能)。
gcloud firestore backups schedules create --database='(default)' --recurrence=daily --retention=7d --project=linto2で設定します。
Firestoreのバックアップは既存データベースへの直接上書き復元ができないため、 「新しいデータベースとして復元してから、内容を確認し本番へ反映する」という2段階の設計になります。
クライアント側バックアップ(インポート・エクスポート機能、実装済み)
設定画面から、保存済みリンクをJSONファイルとして手動でエクスポート・インポートできます
(IMPORT_EXPORT.md参照)。ローカルの復号済みリポジトリ全件を対象に、Androidは
Storage Access Framework、Web版はFile System Access API(非対応ブラウザは通常のダウンロードに
フォールバック)でファイルを保存します。インポートはURL一致で重複を除外する「追記」方式で、
このアプリのJSON形式に加えてChromeのブックマークHTML形式も受け付けます。
E2E暗号化とバックアップ・リストアの根本的な限界
PITR・スケジュールバックアップ・インポート/エクスポートはいずれも「暗号化されたままのデータ、 または復号済みのローカルデータ」を保護する仕組みであり、暗号化パスフレーズの紛失による データ喪失そのものは防げません。これはサーバー側に復旧経路(パスフレーズのリセット手段や バックドア)を一切持たないゼロ知識設計の帰結であり、意図した仕様です。バックアップ設計を どれだけ強化しても、この一点だけは技術的にトレードオフの外に出せない前提として明記しておきます。
障害発生時のログ運用(形式・保管場所・解析手順)
何を記録し何を絶対に記録しないかという方針は前述の 「ログに平文を残さない設計」の通りです。ここでは、実際に障害・不具合が 起きたときに、どの形式のログが・どこに・どれくらいの期間保管され、解析時にどこを見ればよいかを 整理します。
| ログの種類 | 形式・保管場所 | 解析時に見る場所 |
|---|---|---|
| クライアント(アプリ本体)のエラー・警告 |
Firestoreルート直下errorLogsコレクション。1件1ドキュメントで
{ uid, context, message, level: 'error'|'warn', platform: 'android'|'ios'|'web',
createdAt }という固定フィールドのみ(hasOnlyで強制)。クライアントからの
読み取りは不可(createのみ許可)。自動削除の仕組みは無く、アカウント削除後も
意図的に保持される(調査可能性を優先した設計。個人情報保護の観点での定期棚卸しは今後の課題)。
|
Firebase Console → Firestore Database → errorLogs →
uidフィールドで対象ユーザーに絞り込み。contextで発生箇所、
messageでエラー内容、level/platformで種別・端末を確認
(OPERATIONS.md5-1節)。
|
| suggestLinkTags(Cloud Functions/Cloud Run) |
標準出力・例外が自動的にCloud Loggingへ構造化ログとして収集される(severityが
ERRORのエントリにスタックトレースを含む)。保持期間はCloud Loggingの既定
(変更していなければ30日)。
|
Google Cloud Console → Logging → ログ エクスプローラ。または
gcloud logging read 'resource.type="cloud_run_revision" AND
resource.labels.service_name="suggestlinktags"' --project=linto2 --freshness=1dで
HTTPステータス・レイテンシ・エラー詳細を確認(OPERATIONS.md6-2節)。
|
| ai-link-collector(Cloud Run Jobs、週次バッチ) | 標準出力が自動的にCloud Loggingへ収集される。キーワード文字列・収集記事の内容は 一切出力しない(意図的な設計)。 |
ログ エクスプローラでresource.type="cloud_run_job"
resource.labels.job_name="ai-link-collector" textPayload:"uid=<対象uid>"を
クエリし、実行日時(毎週日曜20:00 JST)で絞り込む(OPERATIONS.md5-2節)。
|
| 接続元IPアドレス(不正アクセス調査) | クライアントからは送信・記録していない(自己申告IPは信頼できないため)。Google Cloud Audit LogsのData Accessログを使うが既定では無効(ログ量・コストの都合)。 |
調査が必要になった時点でCloud Console →「IAMと管理」→「監査ログ」から該当APIの
「データ読み取り」「データ書き込み」を一時的に有効化し、
protoPayload.requestMetadata.callerIpを確認後、OFFに戻す運用
(OPERATIONS.md5-3節)。
|
現状の既知の限界として、(1) 未サインイン状態で発生したエラーはFirestoreへ書き込めないため ローカルconsole出力のみに残る(サインイン前の不具合はリモートから追えない)、(2) Cloud Monitoringによる自動アラート・ page 通知は設定しておらず、障害の検知は手動でのログ確認 (週1回の問い合わせフォーム確認等)に依存している、という2点があります。
Google Play Store初期公開に向けた費用対効果評価
ここまでの項目についても、前節と同じ4軸(利便性への影響・対策効果・実装コスト・品質リスク)で 初期公開時点での要否を評価しました。
| 対策 | 利便性への影響 | 対策効果 | 実装コスト | 品質リスク | 初期公開時点での要否 |
|---|---|---|---|---|---|
| PITR有効化 | 無し(バックグラウンドで動作) | 高(直近7日の誤削除・誤更新から復旧可能) | 低(コマンド1つ) | 低 | 推奨 |
| 日次スケジュールバックアップ | 無し | 高(PITRの7日を超えた障害シナリオにも対応) | 低(コマンド1つ) | 低 | 推奨 |
| errorLogsの保持期間ポリシー明文化・定期削除 | 無し | 低(個人情報保護の衛生対策、障害対応そのものには寄与しない) | 低〜中(削除スクリプトの用意が必要) | 低 | 任意(初期公開後でよい) |
| Cloud Monitoringでの自動アラート導入 | 無し | 中(検知の即時性が上がるが、現状の利用規模では手動確認でも実務上足りる) | 中 | 低 | 任意(利用者数が増えてから) |
PITR・日次スケジュールバックアップはApp Checkとは対照的に、ユーザー影響ゼロ・低コスト・ 高い効果という組み合わせのため、Firebase APIキー制限と同様に初期公開前後での 有効化を推奨します。特にアプリ内に「アカウントとデータの初期化」という取り消し不能な 自己サービス削除機能が既に存在するため、実装バグや操作ミスが起きた際のセーフティネットとして PITRの価値は高いと判断しています。
他アプリとの差別化ポイント
- 本文をエンドツーエンドで暗号化するリンク管理アプリは珍しい。 一般的な「あとで読む」系アプリはサーバー側で平文のまま検索・全文インデックスを提供することが多く、 運営者がデータを読める前提の設計がほとんどです。Lintoはリンク・タスク・タグの本文をクライアント側で 暗号化してから送信するため、Firestoreの管理者権限を持つ運営者自身も、パスフレーズを知らなければ 内容を読めません。
- ネイティブとWebで、体感速度を落とさずに同じ暗号強度を実現。 PBKDF2の反復回数を妥協して軽くするのではなく、プラットフォームごとに最適な実装 (ネイティブOpenSSL / ブラウザ標準WebCrypto)を裏側で切り替えることで、OWASP推奨の60万回反復を 維持したまま数十ミリ秒での解錠を実現しています。
- AIタグ提案は要約や本文をサーバーに保存しない。 Gemini呼び出し自体はサーバー側(Cloud Functions)で行いますが、渡すのは復号済みタイトル・説明の その場限りのリクエストのみで、レスポンス(提案タグ)以外はどこにも永続化しません。 AI週次収集バッチのようにサーバー側でデータを常時保持し続ける設計とは明確に切り分けています。
-
ロック中でも共有を取りこぼさない設計。
Web版はセキュリティ上の理由でDEKをメモリのみに保持しタブを閉じるたびにロックされますが、
公開鍵暗号方式の
pendingLinksにより、ロック解除前の共有・ブックマークレット経由の 保存でも取りこぼしません。利便性とセキュリティを両立させるための追加設計です。 - AI収集バッチに静的な秘密鍵が一切存在しない。 Firestore・Gemini(Vertex AI)双方の認証をADC(実行環境のサービスアカウント)に統一し、 漏えいしうる静的なAPIキー・秘密鍵ファイルをインフラ構成そのものから排除しています。
参考文献・参照URL
実装の前提とした仕様・ドキュメントです。
-
RFC 8018 - PKCS #5: Password-Based Cryptography Specification Version 2.1
PBKDF2の仕様。鍵導出方式の根拠。
-
OWASP Password Storage Cheat Sheet
PBKDF2-HMAC-SHA256の推奨反復回数(600,000回)の根拠。
-
NIST SP 800-38D - Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode (GCM)
AES-GCMの仕様。フィールド暗号化に使用。
-
RFC 7748 - Elliptic Curves for Security(X25519)
pendingLinksの鍵交換(ECDH)に使用する曲線の仕様。
-
RFC 5869 - HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
ECDH共有シークレットから暗号鍵を導出する際に使用。
-
W3C Web Cryptography API
Web版の鍵導出(
crypto.subtle)が準拠する仕様。 -
Expo SDK 57 ドキュメント
アプリ基盤のバージョン固有ドキュメント。
-
Firebase Cloud Firestore セキュリティルール
ルールのOR結合の挙動を含む公式ドキュメント。
-
Google Cloud - Application Default Credentials(ADC)
静的キーを持たないサーバーレス基盤の認証方式。
-
Vertex AI - Gemini モデルリファレンス
AIタグ提案・週次AIリンク収集で呼び出すGeminiモデルの仕様。
-
@noble/ciphers(GitHub)
pure TypeScript実装のAES-GCM等。クロスプラットフォームの暗号化フォールバックに使用。
-
react-native-quick-crypto(GitHub)
Nitro Modules経由のネイティブOpenSSL実装。Android/iOSでの鍵導出高速化に使用。