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 |
| アプリ基盤 | Expo SDK 57(Managed Workflow。ネイティブ部分はconfig pluginとprebuildで生成) |
| ルーティング | expo-router(ファイルベースルーティング、Typed Routes有効) |
| アニメーション | react-native-reanimated / react-native-worklets |
| 状態管理 | 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のみ。PBKDF2とフィールドのAES-256-GCM) |
| 端末の本人確認 | expo-local-authentication(Android版のパスフレーズの変更の前と、プライベートのメモ〔1.9.0から〕を表示する前に、生体認証または端末の画面ロックで確かめる。後述) |
| ローカル永続化 | @react-native-async-storage/async-storage |
| 認証・データベース | Firebase Authentication(Googleサインイン。Android版は@react-native-google-signin/google-signinでOSのアカウント選択から取得)/ Cloud Firestore(firebase JS SDK 12.x) |
| ファイルの保存 | Firebase Storage(画像メモの写真と、リンクに添付した画像〔1.9.0から〕。端末でDEKにより暗号化したバイト列だけをusers/{uid}/memoImages/{fileId}・users/{uid}/linkImages/{fileId}に置く。後述) |
| アプリの正当性の確認 | Firebase App Check(Android版は@react-native-firebase/app-checkのPlay Integrity、Web版はreCAPTCHA Enterprise。後述) |
| サーバーレス関数 | Cloud Functions for Firebase(2nd gen、一部のAuthトリガーは1st gen、Node.js 22) |
| バッチ処理基盤 | GCP Cloud Run Jobs + Cloud Scheduler(独立したnpmプロジェクト、Node.js 22) |
| 生成AI | Vertex AI Gemini API(gemini-2.5-flash-lite、@google/genai) |
| ホスティング | Firebase Hosting(マルチサイト構成、GitHub Actionsで自動デプロイ) |
| CI | GitHub Actions(Web版・ドキュメントサイトそれぞれ専用ワークフロー) |
| テスト | Jest / jest-expo / @testing-library/react-native。Firestoreセキュリティルールは@firebase/rules-unit-testing+Firestoreエミュレーターで単体テスト(アプリのJestは2026-10-17時点で192スイート・1,595件) |
| Lint | ESLint / eslint-config-expo |
システム構成
クライアント(Android版・Web版)はFirebase Authentication・Cloud Firestore・Firebase Storage(画像メモの写真・リンクに添付した画像。どちらも暗号化したもの)と直接通信します。 サーバーサイドのロジックは、AIタグ提案・似ているタグの統合の提案・サイトのアイコンと代表画像(サムネイル)の取得(対話的・低レイテンシが必要)は Cloud Functions、AIリンクの収集(毎日の新着と、週2回のAIによる注目の選定。バッチ・長時間実行)はCloud Run Jobsと、 用途に応じて実行基盤を分けています。 どちらもGeminiの呼び出しにはVertex AIを使い、Firestoreアクセスを含めて静的なAPIキー・秘密鍵を一切持たず、 実行環境にアタッチしたサービスアカウントのApplication Default Credentials(ADC)だけで認証します。
ポイントは「同じFirestoreプロジェクトの中に、暗号化必須のデータ(link/task/memo/tag/habit、画像メモの写真・リンクに添付した画像〔Storage〕)と、 サーバー側処理のためにあえて平文のままにしているデータ(AIキーワード・AIリンクとその評価・AIエージェントの設定と 成長・持ち物等・週次メッセージ・AIリンクの索引と削除した記事の一覧)が併存している」ことです。この区分と、それぞれの保護方法は セキュリティ設計で詳しく説明します。
なお、AIタグ提案(suggestLinkTagsBatch等)は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へ送信します (画像メモの写真・リンクに添付した画像も同じ鍵で暗号化してからFirebase Storageへ置きます)。 復号鍵はユーザーが設定したパスフレーズから導出し、パスフレーズ自体はネットワークに一切送信しません。 Firestoreのデータが仮に漏えいしても、パスフレーズを知らない限り内容を読むことはできません。 詳細はセキュリティ設計を参照してください。
3. ロック解除なしでも共有できる「ブックマークレット即時保存」
Web版はDEK(データ暗号化鍵)をメモリ上にしか保持しないため、タブを閉じるたびにロックされます。
一方でブックマークレットからの共有はロック解除前でも発生しうるため、通常の暗号化フローとは別に、
公開鍵暗号(X25519 + HKDF + AES-256-GCM)だけでURLを安全に一時保存できるpendingLinks
という仕組みを別途用意しています。秘密鍵を必要とせず公開鍵だけで暗号化できるため、ロック中でも
データを失わずに受け取れます。詳細はセキュリティ設計内の解説を参照してください。
4. Firestoreルールの「OR結合」を逆手に取らせない設計
Firestoreセキュリティルールは複数のmatchブロックがOR結合されるため、
「ユーザー自身の書き込みを許可する緩いワイルドカードルール」と「サーバーのみ書き込み可能な
厳格なルール」が両方存在すると、後者をusers/{uid}/配下に置いた場合に前者の緩いルールが
先に一致してしまい、改造クライアントから直接書き込めてしまう抜け道になります。AIタグ提案等の
レート制限カウンタ(aiTagLinkUsage / aiTagUsage / siteIconUsage等)と
上限の設定(全体の既定値のconfig/plans、利用者ごとの例外のlimitOverrides/{uid}。旧来の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へ移行したことでこれらの静的シークレットそのものを
削除できました。
6. 差分同期と「削除済みの印」による確実な削除の反映
同期はFirestoreの購読(onSnapshot)でリアルタイムに行いますが、起動のたびに全件を
読み直すと、件数に比例して読み取り回数(課金)と起動時間が増えます。そこで各ドキュメントにサーバー
時刻の最終更新時刻(serverUpdatedAt)を記録し、前回の同期以降に更新されたものだけを
購読する差分同期にしています(端末の時計のずれや書き込みの反映の遅れに備え、
前回の時刻より5分前から取り直します)。
差分だけを読むと、アプリを閉じていた端末には「削除」が伝わりません(物理削除したドキュメントは 差分の問い合わせに現れないため)。そこでリンク・タスク・メモ・タグ・タグのグループ・習慣・習慣の記録の削除は、内容を消した 削除済みの印(トゥームストーン)への上書きとして同期し、受け取った端末が 手元のデータから削除します。削除済みの印には暗号化した識別情報と日時だけを残し、180日後に FirestoreのTTLポリシーで自動的に物理削除します。180日より長く同期していなかった端末や、万一の 食い違いに備えて、7日に1回はサーバーの全件と照合します。また、通信できない間の変更は端末に 「送信待ち」として残し、再接続時に再送します。
AIリンク(サーバーの収集バッチが書く平文のデータ)も、1.2.6から一覧を開くたびの全件の読み込みをやめ、端末の控えと
serverUpdatedAtによる差分だけを読む形にしています。収集バッチが保つ索引(aiSettings/aiLinkIndex、
1ドキュメント)を読んで、索引に無い記事を控えから消します。
1.4.1からは、AIリンクをアプリから削除できます。アプリは記事のドキュメントを消さずにdeleted: trueと
deletedAtを付け、すぐに一覧から外します(「元に戻す」は印を外すだけです)。次の収集バッチの実行で、印の付いた記事の
ドキュメントを消して索引から外し、URLを削除した記事の一覧(aiSettings/aiLinkDismissed、新しい順に最大1,000件)に
加えます。これにより、削除した記事は保存数の上限に数えられず、同じ記事を再び集めることもありません。削除は「興味なし」(👎)の
評価としては扱わず、似た記事を選ばない参考にはしません(評価の履歴aiFeedbackも変えません)。
7. サイレント故障にしない監視と、利用者のデータを載せないエラー記録
処理を続けられないエラーは、握りつぶさずにエラーID(E-YYYYMMDD-XXXXX)付きのポップアップで
利用者に知らせ、同じIDでerrorLogsに記録します。問い合わせ時のスクリーンショットのIDから、
該当する記録をすぐに特定できます。アプリが強制終了する例外は端末に記録を残し、次回の起動時に表示します。
React Nativeでは描画中の例外がグローバルな例外の受け口を通らずに強制終了するため、Expo Routerの
ルート全体をエラーバウンダリで包んで受け止めています。
本番のerrorLogsの記録はFirestoreトリガーのCloud Function(relayErrorLogs)が
Cloud Loggingへ橋渡しし、Cloud Monitoringのアラートで開発者へメール(スマートフォンへの通知を含む)を
送ります。DoS・異常なトラフィック(Firestoreの読み書き・Cloud Functionsの呼び出しの急増)、サーバー側の
エラーも同じ仕組みで検知します。
8. 第三者に保存先を知らせないサイトのアイコンの取得
リンクのサイトのアイコン(favicon)は、Googleのfaviconサービス等を使えば簡単に表示できますが、
利用者が保存したサイトが第三者に伝わり、URLを端末で暗号化している意味が薄れます。そこで自前の
Cloud Function(fetchSiteIcon)がサイトのトップページの<link rel="icon">
等から候補を選んで取得し、64px四方のPNGに変換して返します(PNG・ICO・JPEG等をsharpで変換。URL・ドメインは
記録しない)。外部のサイトの画像を読む処理のため、SVGは読みません(画像の処理ライブラリのSVGの読み込みに脆弱性が
見つかった際、ライブラリを更新したうえで、SVGの読み込み自体を止めました。SVGのアイコンしか無いサイトは既定のアイコンになります)。アプリは受け取ったアイコンを、URL・タイトルと同じくDEKで暗号化して保存します。
利用者が指定したURLへサーバーから接続するため、内部のネットワーク(メタデータサーバー等)へ接続させられる 攻撃(SSRF)を防ぐ制限を入れています: 名前解決した全てのアドレスが公開アドレスの場合だけ接続する (DNSリバインディング対策として、接続時の名前解決で判定)、リダイレクト先も同じ判定をする、標準以外の ポート・認証情報付きのURLは使わない、応答の大きさ・時間に上限を設ける。1ユーザーあたり1日の回数にも 上限があります。既存のリンクへの付与は、並び順(更新日時)や自動の倉庫送りの判定を変えないよう、 各端末の中だけで行い同期の書き込みを増やしません(アイコンはサイトから決まるため、各端末で取得すれば 足ります)。利用者が編集画面で設定・削除したアイコンは、通常の保存として同期します。
1.7.0からは、リンク・AIリンクのカードに出すページの代表画像(OGP画像。サムネイル)も、同じ考え方の自前の関数
(fetchLinkThumbnail)で取得します。ページのog:image等から候補を選び(YouTubeの動画は動画のIDから)、320×180のJPEGに変換して返します
(SSRF対策・回数の上限の考え方はアイコンと同じ。URL・画像は記録しない)。代表画像は表示のためだけのものなので、同期も暗号化した保存もせず、
受け取った端末のキャッシュにだけ置きます(30日・1,500件を超えたものから消す)。Google ニュースの記事は取得できないため、
取りに行かずに媒体名の頭文字の枠を出します。
9. AIを使う部分と使わない部分を分けたAIリンクの収集
AIリンク(登録したキーワードに関する記事を集める機能)の収集バッチは、費用の大半を占めるGeminiの呼び出しを増やさずに
記事を毎日届けるため、2段構えにしています(2026-10-12から)。毎日20時の新着はAIを使わず、
Google ニュース・Bing ニュース・はてなブックマーク・Zenn・Qiita・note等のRSSから候補を集め、有料記事・既存の記事・
重複(URLの正規化と、タイトルの文字の組の重なり)・利用者が👎を付けた記事に似たものをプログラムだけで除いて保存します。
同じキーワードのRSSは1回の実行で1度だけ取得し、そのキーワードを登録した利用者の間で使い回します(メモリ上のみ)。
水曜・日曜の注目だけ、溜まった新着からGeminiが選んで要約・「なぜ読むべきか」を付けます
(呼び出しは利用者ごとに1回。URLはAIの出力を使わず、番号から元の記事を引き直します)。利用者が削除の印を付けた記事は、
注目の候補から除きます。3段目のおすすめ(利用者の「興味あり」の評価をもとにGeminiが選ぶ記事。設定画面でオフにできる)は
日曜の実行で作ります(上限の設定のaiRecommendRunsPerWeekが2の場合は水曜も)。
1.4.0からは、利用者がAIエージェントに「本」を渡すと、その本の観点(入門向け・データのある記事等)が7日の間、Geminiの
記事の選び方に加わります(関連性・重要度が同じくらいの記事どうしで優先するだけで、キーワード・件数は変えません)。バッチは
aiSettings/agentItemsの読んでいる本を読み、本のidを決まった一覧に照らして、利用者の書いた文字は指示に入れません。
週次のコメントは日曜の実行でだけ、利用者が12問の診断(または一覧からの選択)で決める16種類のAIエージェントの口調で Geminiが書きます。エージェントの名前は利用者の入力がそのままGeminiへの指示に入るため、バッチ側で長さを切り詰め、改行・制御文字や 括弧・引用符を除いてから「名前として扱う値」として埋め込みます。また、しばらくアプリを使っていない利用者の収集は休み、 アプリを開けば次の収集から再開します。
AIエージェントの成長(親密度)・プレゼントの持ち物(1.4.0から)は、users/{uid}/aiSettings/配下の平文の
ドキュメント(agentGrowth・agentItems)に置きます。1.5.0からはこれらをエージェントの種類ごとに持ち、
他のエージェントの分はagentArchiveに預けます。選ぶエージェントを変えた時は、アプリがFirestoreのトランザクションで
今の分を預け、選んだエージェントの分(無ければ0から)を入れ替えます。今の文書の場所と形は変えないため、収集バッチは
今までどおりagentGrowth・agentItemsを読むだけで、今のエージェントの分を扱えます。
セキュリティ設計(詳細)
ここでは、他の開発者の方の参考になるよう、暗号化・鍵管理・アクセス制御の設計をできるだけ具体的に 説明します。まず前提として、このアプリの脅威モデルを明確にしておきます。
守る対象: Firestore・Storageのデータそのものが漏えいした場合でも、リンク・タスク・メモ・タグ・習慣の本文 (URL・タイトル・説明・チェックリスト・メモの本文・タグ名・関連リンク・サイトのアイコン・習慣の名前と頻度・画像メモの写真・リンクに添付した画像)を パスフレーズなしに読めないようにすること。
守らない対象(既知のトレードオフ): 作成日時・更新日時、タスクの日付・時刻・進捗・完了・リマインダー、お気に入り・倉庫・完了の フラグ、習慣の色・目標の回数・並び順と実施の記録(日付・回数)等のメタデータは暗号化していません。 また、AIキーワード・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に保存します(パスフレーズ自体はどこにも保存・送信しません)。
パスフレーズはログイン用ではなく鍵の元になるため、Firestoreのデータが漏れた場合のオフラインでの総当たりに耐えることが
重要です。そこで新規設定・再設定の時は、NIST SP 800-63Bの考え方に沿って、12文字以上(最大128文字)、
文字の種類の組み合わせは求めず、よく使われるパスワードの一覧(上位1万件、端末に同梱)・同じ文字や語の繰り返し・
連続した文字やキーボードの並び・サービス名やメールアドレスを元にしたものを弾きます(外部のサービスには問い合わせません)。
NISTはUnicodeの受け付けを推奨していますが、Androidのパスワード用の入力欄ではキーボードが半角英数字に固定されるため、
全角のパスフレーズをWeb版で設定するとAndroid版でロックを解除できなくなります。このため例外として
半角の英数字・記号・スペースだけを受け付けます。ロック解除の時は要件を確認しないため、この要件の導入前に
設定したパスフレーズもそのまま使えます(src/domain/passphrase-policy.ts)。
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等で読み取られるリスクが上がるためです。
利便性より安全性を優先したトレードオフとして、意図的にこの設計にしています。
パスフレーズの変更(DEKの再ラップ、1.5.1)
1.5.1から、Android版でロックを解除済みの端末では、今のパスフレーズを入力せずにパスフレーズを変更できます (パスフレーズを忘れても、ロック解除済みの端末が1台あれば取り戻せるようにするため)。変更の前に、expo-local-authenticationで 生体認証または端末の画面ロック(PIN・パターン・パスワード)による本人確認をします(生体認証が使えない場合も画面ロックで確かめます。 端末に画面ロックが無い場合は、確認の画面だけで進みます)。新しいパスフレーズには、新規設定と同じ要件を確認します。
- DEKは変えずに包み直す: 新しいソルトを作り、新しいパスフレーズから600,000回のPBKDF2でKEKを導出して、 端末が持つ同じDEKをラップし直します。データの暗号文は変わらないため、全件の送り直しは不要で、 画像メモの写真もそのまま読めます。ロック解除済みの他の端末もそのまま使えます。
- サーバーの鍵と同じかを確かめてから書く:
users/{uid}/cipherMaterial/mainへの書き込みはFirestoreの トランザクションの中で行い、サーバーのwrappedPendingLinkPrivateKey(DEKでラップした秘密鍵)を端末のDEKで アンラップできた場合だけ書きます。結果はchanged(変更した)・key-mismatch(他の端末で再設定されていて 鍵が違う。何も書かず、この端末はロックする)・cannot-verify(確かめられない。何も書かない)の3つです。 古いDEKで包み直して、他の端末の新しいデータをどこでも読めなくしてしまうことを防ぎます。 - Web版では出さない: 本人確認の手段が無いため、Web版は今までどおりの「再設定」(新しいDEKを作る)だけです。
パスフレーズは変更の時も保存・送信・表示しません。この機能でAndroidの権限は増えていません
(本人確認に使うUSE_BIOMETRICは1.5.0から含まれていました)。
暗号化フィールド一覧
| エンティティ | 暗号化する項目 | 平文のまま保存する項目 |
|---|---|---|
| Link | url, title, description, tags(JSON配列), icon(サイトのアイコン), images(添付した画像の一覧、JSON。1.9.0から) | archived, isFavorite, aiTagSuggested, tagsEditedByUser(利用者がタグを変えた印、1.11.0から), version, createdAt, updatedAt, contentUpdatedAt |
| Task | title, description, checklist(JSON), tags(JSON), linkedLinks(関連リンクのURL、JSON。1.2.4から) | id, 日時関連, allDay, manualProgress, completed, calendarReminders, isFavorite, version, createdAt, updatedAt |
| Memo | title, body, tags(JSON), linkedLinks(関連リンクのURL、JSON), images(画像メモの写真の一覧、JSON) | id, version, isFavorite, completed, kind(画像メモは'photo'), isPrivate(プライベートの印、1.9.0から), createdAt, updatedAt, contentUpdatedAt |
| 画像メモの写真(Firebase Storage) | 写真のバイト列(本体と縮小画像。AES-256-GCM、DEK。「12バイトのノンス+暗号文+認証タグ」の1ファイル) | (ファイルのパスusers/{uid}/memoImages/{fileId}・大きさのみ。リンクに添付した画像〔1.9.0から〕も同じ形でusers/{uid}/linkImages/{fileId}に置く) |
| 習慣(Habit) | title, frequency(頻度、JSON) | color, target(1日の目標の回数), order, archived, createdAt, updatedAt |
| 習慣の実施の記録(habitLogs) | (対象外) | habitId, date, value, updatedAt(何の習慣かは習慣の側の暗号化した名前でしか分からない) |
| 登録済みタグ(リンク用 / タスク・メモ用) | name | createdAt, updatedAt, origin(AIが作ったか利用者が作ったか、1.11.0から) |
| タグの別名(tagAliases、リンク用のタグの統合の記録。1.11.0から) | from, into, reason | source(表記揺れ・AI・吸収・手動), status(有効・取り消し), createdAt, updatedAt |
| タグのグループ | name, tags(JSON配列) | id, order, createdAt, updatedAt |
| AIキーワード | (対象外) | keyword, createdAt |
| AIリンク | (対象外) | url, title, summary, whyRead, keyword, publisher, kind, source, rating, publishedAt, createdAt, deleted, deletedAt(1.4.1から) |
| AIリンクの評価の履歴(aiFeedback) | (対象外) | url, title, summary, keyword, rating, updatedAt |
| AIエージェントの設定 / 週次メッセージ | (対象外) | agent(種類・名前・呼び方), message, updatedAt/createdAt |
AIエージェントの成長・個性等(aiSettings/配下) | (対象外) | agentGrowth(親密度), agentTraits(個性), agentCards(カードのアルバム), agentEpithets(二つ名・名前の記録), agentItems(プレゼントの持ち物・読んでいる本、1.4.0から), agentArchive(他のエージェントの分の親密度・持ち物、1.5.0から), agentGame(ミニゲームのベスト、1.6.0から), agentStyle(こだわり、1.7.0から), tagAutomation(タグの自動化のオン・オフ、1.11.0から) |
AIリンクの運用データ(aiSettings/配下) | (対象外) | recommend(おすすめのオン・オフ), activity(アプリを使った時刻。収集を休む判断), aiLinkIndex(AIリンクの索引), aiLinkDismissed(削除した記事のURL、1.4.1から) |
| pendingLinks | url, title(X25519公開鍵暗号方式、後述) | ephemeralPublicKey, createdAt |
並び替え・絞り込み・検索は、復号した後に端末の中で行うため、メタデータを平文にしている理由はそこではありません。
平文にしているのは、サーバー(Firestore)と同期の仕組みがそれらを読む必要があるためです。差分同期は
serverUpdatedAt(サーバー時刻)を条件に「前回以降に更新されたもの」だけを問い合わせ、
削除済みの印はdeletedで見分け、expireAtをFirestoreのTTLポリシーが読んで自動的に物理削除します。
また、2つの端末で同じものを変更した時はupdatedAtの新しい方を採るため、復号せずに比べられるようにしています。
日時・完了・お気に入り等のフラグには、内容に比べて機密性があまり無いことも踏まえた判断です。なお、タスクの関連リンクは1.2.3まで平文(linkedLinkUrls)で
送っていましたが、1.2.4で暗号化したlinkedLinksに改め、全件照合の際に既存のデータも暗号化した形で送り直しています。
メールアドレス等のアカウント情報の保存・管理方針
上の表の対象はいずれも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', 'errorId']に 厳格に制限しており、コード側で誤って追加しようとしてもルール違反で書き込み自体が失敗します (ログに平文を残さない設計も参照)。 -
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;
}
// config/plans(上限の既定値)・limitOverrides/{uid}(利用者ごとの例外)はルールを書かない=既定で読み書きとも拒否
match /appConfig/{docId} { // 起動時の更新の案内(Android版 1.10.0 から)
allow read: if true; // 最新の版の番号だけで秘密の情報は無いため、サインイン前でも読める(App Checkの強制は掛かる)
allow write: if false; // 書き込みはFirebase Console(Admin)だけ
}
appConfig/androidには最新の版(latestVersionCode・latestVersionName)と、それより古いと使えなくする版
(minVersionCode)を置きます。Android版は起動の約3秒後(裏から戻って12時間以上経った時も)にこれを読み、新しい版があれば
お知らせ(同じ版は一度だけ)を、minVersionCodeより古ければ閉じられない案内を出して、Google Playのページを開けるようにしています。
今の上限は、コードに持つ既定値にconfig/plansの値とlimitOverrides/{uid}の例外を重ねて、
Cloud Functions・収集バッチがその都度計算します(利用者ごとの値を書き写さないため、既定値の変更がすぐに反映されます)。
アプリは表示のためにgetMyLimitsで計算済みの値を受け取るだけで、強制は各関数・収集バッチが行います。
レート制限自体は、Cloud Functions側でFirestoreのトランザクションを使って実装し、読み取りと書き込みの間に
競合が起きないようにしています。2026-09-27からは、複数のリンク(最大10件)を1回でまとめて提案する
suggestLinkTagsBatchで、上限をGeminiへ送るリンクの件数(1ユーザー1日30件〔2026-10-08に
50件から見直し〕と、全体の1日の件数)で数えています。件数はGeminiを呼ぶ前に差し引くため、提案が不正だった・
アプリが結果を反映できなかった場合も数えられます。送る内容(タイトル200字・説明1,000字・既存タグ一覧)と
出力トークン数にも上限を設け、思考は無効にしているため、「上限の件数×1件あたりの最大費用」が費用の上限に
なります。また、同じ利用者が同じサイトのリンクに付けてきたタグで決まる場合は、端末内で再利用して
AIを呼びません(利用者をまたいだ共有のキャッシュは、URL・説明に私的な情報が含まれうるため採用していません)。
似ているタグの統合の提案(suggestTagMerges)も同じ考え方で、アプリから送るのはタグ名と使用件数だけにし、
1日の回数(利用者ごと・全体)を呼ぶ前に数えます。統合そのものは、利用者が確認した後にアプリが行います。
いずれも、使用したトークン数を件数とともにログに残し(送った内容は残しません)、費用の見積もりと突き合わせています。
最後の歯止めとして、2026-10-31からは月の予算(Cloud Billingの予算)の実績が100%に達すると、予算の通知をPub/Sub経由で受けた関数
(stopCostlyFeaturesOnBudget)が、AI・アイコンと代表画像の取得などの費用のかかる機能を停止スイッチ(config/aiFeatures)で
自動的に止めます(自動では戻さず、原因を確かめてから手で戻します)。
Gemini API・各サーバーへのアクセス制御と最小権限設計
Firestore以外のバックエンド(Cloud Functions・Cloud Run Jobs・Vertex AI Gemini API)についても、 「誰が」「どの権限で」呼び出せるかを整理しておきます。
| コンポーネント | 呼び出し元を制限する仕組み | 実行時の権限(IAM) | 状態 |
|---|---|---|---|
| Cloud Firestore | セキュリティルールでrequest.auth.uid一致のみ許可 |
- | 実装済み(前述) |
| Firebase Storage (画像メモの写真・リンクに添付した画像) |
Storageのセキュリティルールで、users/{uid}/memoImages/・users/{uid}/linkImages/(1.9.0から)配下を本人(request.auth.uid一致)だけが読み書き・削除できる。
書き込みは1ファイル2MB未満(リンクに添付した縦に長い画像は4MB未満)・種類application/octet-stream(暗号化したバイト列)だけを受け付ける |
- | 実装済み |
| suggestLinkTagsBatch・suggestLinkTags(旧版のアプリ向け)・suggestTagMerges (Cloud Functions 2nd gen) |
Firebase AuthのIDトークンを関数コード側で検証(request.auth)。App Checkの検証結果も確認(後述) |
専用のサービスアカウントに、Vertex AI・Firestoreの利用に必要な最小限のロールのみ付与 | 実装済み(最小権限) |
| Vertex AI Gemini API(Cloud Functions経由) | クライアントから直接は呼べず、上記Cloud Functionsのサービスアカウント経由のみ | 同上 | 実装済み |
| ai-link-collector (Cloud Run Jobs) |
Cloud Schedulerのみが対象Jobへのrun.invoker権限を持つ(外部から直接execute不可) |
専用のサービスアカウントに、Firestore・Vertex AIの利用に必要な最小限のロールのみ付与 | 実装済み(最小権限) |
| checkWebAccess (Cloud Functions 2nd gen) |
Firebase AuthのIDトークンを関数コード側で検証。Web版の利用を、許可した接続元・Googleアカウントに限定する判定を行う(接続元はサーバー側で取得し、利用者による偽装を受け付けない) | 同上の専用サービスアカウント | 実装済み(2026-09-27) |
| fetchSiteIcon・fetchLinkThumbnail(代表画像、2026-10-25から) (Cloud Functions 2nd gen) |
Firebase AuthのIDトークンを関数コード側で検証。それぞれ1ユーザー1日100回の上限(無料プラン)と、全体の1日の上限。接続先を公開アドレスに限定(SSRF対策) | 同上の専用サービスアカウント | 実装済み(2026-09-27) |
| getMyLimits (Cloud Functions 2nd gen) |
Firebase AuthのIDトークンを関数コード側で検証。利用者の今の上限を返すだけ(表示用)。App Checkは記録のみで拒否はしない | 同上の専用サービスアカウント | 実装済み |
| relayErrorLogs (Firestoreトリガー) |
クライアントからは呼べない(errorLogsへの書き込みをEventarc経由で受け取る) |
同上の専用サービスアカウント | 実装済み(2026-09-27) |
| 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 App Check(2026-10-10に導入、2026-10-16から費用のかかる関数で、2026-10-31からFirestore・Storageでも強制)で段階的に強化しています。
サービスアカウントの権限設計は、ai-link-collector(Cloud Run Jobs)・
functions/(Cloud Functions 2nd gen/1st gen)とも専用のサービスアカウントを
新規作成し、Firestore・Vertex AIに必要な最小限のロール(roles/aiplatform.user・
roles/datastore.user)のみを付与する方針で統一しています。
また、全ての関数に同時実行インスタンス数の上限を設定し、大量の呼び出しによる費用の急増を防いでいます。
Firebase App Checkの段階導入(2026-10-10導入、2026-10-16からCloud Functions、2026-10-31からFirestore・Storageで強制)
Firestoreのセキュリティルール(uidによるアクセス制御)・E2E暗号化(サーバーが本文を読めない)・ Cloud FunctionsでのFirebase Authの検証・最小権限のサービスアカウント・利用者ごと/全体の1日の上限という 複数の防御に加えて、リクエストが改ざんされていない正規のアプリから来ていることを検証する Firebase App Checkを、2026-10-10に導入しました(Android版はバージョン1.2.2から)。
- Android版: アプリのFirestore・Functions・AuthはJS SDKのまま使い、App Checkだけをネイティブの
@react-native-firebase/app-check(本番版はPlay Integrity、テスト版・開発用のビルドはデバッグのプロバイダ)で取得し、
JS SDKの
CustomProviderに渡しています(有効期限はトークン〔JWT〕のexpから求める)。 - Web版: JS SDKのApp Check+reCAPTCHA Enterpriseのプロバイダ。CSPにreCAPTCHAとApp Checkの通信先を加えています。
- Cloud Functions: 各callable functionの入り口で検証結果(
request.app)を確認します。onCallのenforceAppCheckは使わず、Firestoreの設定(config/appCheck)で強制・解除を 切り替えるため、問題が起きても再デプロイなしで即座に解除できます。強制の対象は費用・外部への接続を伴う 関数(AIタグ提案・タグの統合の提案・サイトのアイコンと代表画像の取得)で、Web版の接続確認・上限の表示用の関数は記録のみです。
App Checkは、判定を最初から強制すると、正規の利用者の端末を誤って拒否してしまう恐れがあります (Play Integrityの判定は、端末の環境やアプリの配信状況によって安定するまでに時間がかかることがあります。 また、App Checkに対応する前の版のアプリはトークンを送りません)。 そこで、利用者の使い勝手(品質)とセキュリティのバランスをとり、次の順に進めています。
- トークンを送るだけで拒否はしない「監視」の段階で、検証の成功率を確かめる(検証できなかった呼び出しは関数名だけを ログに残し、Cloud Monitoringの指標で見ています)
- 正規のアプリ(Playで配布した本番版・本番のWeb版)からの呼び出しがほぼ全て検証済みであることを確かめ、 費用のかかる処理(Geminiの呼び出し)とアイコンの取得から強制する(2026-10-16に実施。 トークンの無い・偽の呼び出しが拒否され、正規のアプリは止まらないことを確かめました)
- その後、Firestore・Storageへ対象を広げる(Firestoreの強制はプロジェクト全体にかかり、 検証に通らない端末は同期が全て止まるため、日ごとの「検証済み」の割合が続けて99%以上になり、 検証に通らない通信の送信元を特定してから判断しました。Firestoreの検証済みが7日続けて99.4〜100%になったことを確かめ、 2026-10-31に実施。現在の段階)
なお、Firebase APIキーには、使えるAPIの制限と、Web用キーの参照元の制限を設定しています。
ログに平文を残さない設計
復号済みの本文(タイトル・説明・URL等)はログに一切出力しません。エラーログ共通窓口
(logError/logWarn)はローカルのconsole出力に加え、保守運用の
問い合わせ対応のためにuid付きでFirestoreのerrorLogsコレクションへも
記録しますが、書き込み可能なフィールドをhasOnly([...])で厳格に許可リスト化し、
context/message文字列にサイズ上限を設け、クライアントからの
読み取り・更新・削除は一切許可していません(createのみ、確認は運用手順書に基づき
コンソール側で行う)。記録するエラーの内容(message)も、送信前に無害化します
(JSONの解析エラーは内容を記録しない、URL・メールアドレス・二重引用符で囲まれた部分を伏せる、
300文字に切り詰める)。エラーのメッセージには、処理中のデータの断片(端末内の復号済みの本文)が
入ることがあるためです。開発者へのアラートのメールには、uid・発生箇所・端末の種類・
エラーID・ドキュメントIDのみを載せ、エラーの内容は載せません。
クライアントIPは自己申告させず(詐称可能なため)、Web版の接続制限(checkWebAccess)では
サーバー側で取得した値を使います。拒否した場合のみuid・接続元IP・理由をCloud Loggingに
記録します。それ以外の調査が必要な場合は、Google Cloudの監査ログを使います。
Webアプリケーションの脆弱性対策(OWASP Top 10:2025)
Web版(PC)とAndroid版は同じコード・同じサーバー(Firebase・Cloud Functions)を使うため、一般的なWebアプリケーションの 脆弱性への対策は両方に効きます。ここでは、Webアプリケーションの代表的なリスクをまとめた OWASP Top 10:2025の10項目と、特に問われることの多いSQLインジェクション・XSS・CSRFについて、 実装で確認した対応状況を記載します(2026-09-28時点。コード・テスト・公開中のサイトの応答ヘッダーで確認しています)。
OWASP Top 10:2025の各項目への対応
| 項目 | このアプリでの対応 |
|---|---|
| A01 アクセス制御の不備 | Firestoreのルールで、利用者は自分のusers/{uid}配下しか読み書きできません。
ルート直下のカウンター・エラーログはクライアントから読めない(エラーログは作成のみ、項目を許可リストで制限し、
端末の種類は決められた値、記録の日時はサーバーの時刻だけを受け付ける)設計で、
ルールのOR結合の落とし穴も避けています(前述)。Cloud Functionsは全てサインインを必須にし、
処理対象は常にサーバー側で取得したuidで決めます(クライアントが送るIDを信用しない)。 |
| A02 セキュリティ設定のミス | Web版・公開サイトとも、CSP(script-src 'self'、object-src 'none'、
frame-ancestors 'none'等)、X-Frame-Options: DENY、X-Content-Type-Options: nosniff、HSTSを返します。
サーバーの各処理は専用のサービスアカウントで最小権限に絞っています(前述)。 |
| A03 ソフトウェアのサプライチェーンの不備 | 依存パッケージはpackage-lock.jsonで版を固定し、Web版の自動デプロイは
npm ciで同じ版を入れます。npm auditで既知の脆弱性を定期的に確認し、深刻度「高」「重大」が無いことを
確かめています(それ以外も、影響を評価したうえで上位のパッケージの更新に合わせて解消します)。 |
| A04 暗号化の失敗 | リンク・タスク・メモ・タグ・習慣の本文と画像メモの写真・リンクに添付した画像は、利用者のパスフレーズから導出した鍵(PBKDF2-HMAC-SHA256、60万回)で AES-256-GCMによりエンド・ツー・エンド暗号化します。通信はHTTPSのみです(前述)。 |
| A05 インジェクション | SQLのデータベースを使わず、クエリを文字列で組み立てる処理がありません(下記)。 HTMLを直接埋め込む処理も無く(下記のXSS)、AIへ渡す入力は長さ・件数を制限し、AIの出力は形式を検証してから使います。 |
| A06 安全でない設計 | 脅威モデルを先に決め(前述)、サーバーが本文を読めない設計にしています。 AI・アイコンの取得等の外部への処理は、利用者ごと・全体の1日の上限で濫用を防ぎます。設計の点検で見つかった ブックマークレットの経路の問題(下記のCSRF)も、設計から見直して修正しました。 |
| A07 認証の失敗 | 認証はGoogleアカウント(Firebase Authentication)に任せ、このアプリでパスワードを保存・検証しません。 暗号化のパスフレーズはサーバーへ送らず、鍵の導出に十分な反復回数を使います。Web版は接続元・アカウントの許可も確認します。 |
| A08 ソフトウェアとデータの整合性の不備 | AES-GCMの認証タグで、暗号文の改ざんを検知します。更新は版数(楽観ロック)で 古い内容による上書きを防ぎます。Web版・公開サイトは、リポジトリから自動デプロイします。 |
| A09 セキュリティのログと警告の不備 | エラーを利用者のデータを含めずに記録し、発生を開発者へ自動で通知します。 Web版で接続を拒否した場合は、理由と接続元を記録します(前述の「ログに平文を残さない設計」)。 |
| A10 例外的な状況の不適切な処理 | 判定できない・失敗した場合は安全な側に倒します(例: ブックマークレットの合言葉を読めなければ 確認なしでは保存しない、アイコンの取得で名前解決できない宛先には接続しない、AIの提案が不正なら適用しない)。 処理を続けられないエラーは、エラーIDを表示して調査できるようにしています。 |
SQLインジェクション
データの保存にはSQLのデータベースを使わず、Cloud Firestore(ドキュメント型のデータベース)をSDK経由でだけ読み書きします。 検索の条件は、利用者の入力を文字列としてクエリに埋め込むのではなく、SDKの引数として渡すため、入力がクエリの構文として 解釈されることはありません(SQLインジェクションの成立する前提がありません)。さらに、リンク・タスク・メモの本文は 暗号化して保存しているため、本文の検索はサーバーではなく端末で復号した後に行います。サーバー(Cloud Functions)でも SQLは使っていません。
クロスサイトスクリプティング(XSS)
- HTMLを直接埋め込まない: 画面はReact(React Native for Web)で描画し、文字列は常にテキストとして
表示されます。
dangerouslySetInnerHTML・innerHTML・eval等は使っていません。 メモのマークダウンもHTMLへ変換せず、字句解析(markedのレキサー)の結果をアプリの部品(テキスト・表等)で 描画します(マークダウン中のHTMLは文字として扱い、画像は読み込みません)。 - URLのスキームの検証: リンクを開く直前に、http・https以外(
javascript:・data:等)を 開かないように検証します。共有・ブックマークレットから受け取るURLも、保存する前にhttp・httpsで長さの上限以内のもの だけを受け取ります(2026-09-28に追加)。 - CSP: 万一スクリプトを差し込まれても実行されにくいよう、スクリプトは自分のオリジン (とGoogleのサインイン、App CheckのreCAPTCHA)からだけ読み込むCSPを設定しています。インラインのスクリプトも許可していません。
クロスサイトリクエストフォージェリ(CSRF)
- Cookieのセッションを使わない: サーバーの呼び出し(Firestore・Cloud Functions)は、Firebase Authenticationの IDトークンをリクエストに付けて認証します。ブラウザが自動で送るCookieで認証しないため、他のサイトから利用者の権限で サーバーの処理を呼び出させることはできません。
- ブックマークレットの経路(2026-09-28に修正): Web版には、見ているページをワンクリックで保存する
ブックマークレットがあり、
/link/new?url=...を開くと確認なしで保存していました。このため、他のサイトが利用者の ブラウザにこのURLを開かせると、利用者が意図しないリンクを保存させられる問題がありました(データを読まれることは ありません。Web版の公開前の点検で見つけました)。現在は、利用者ごとの合言葉(32バイトの乱数、本人しか読み書き できない場所に保存)を設定画面で作ってブックマークレットに埋め込み、合言葉が一致した場合だけ確認なしで保存します。 一致しない場合は、編集画面または「保存する」を押す確認を挟みます。他のサイトは合言葉を知らないため、勝手に保存 させることはできません(OWASPのCSRF対策の「トークン」と同じ考え方です)。 - Android版のディープリンク(2026-10-02に修正、バージョン1.1.5から反映): Android版のリンク追加画面は、
他のアプリの共有メニューからだけでなく、ディープリンク(
linto2prod://link/new?url=...)でも開けます。 Webページや他のアプリが偽のURL・タイトルを入れた状態でこの画面を開き、利用者が戻る操作をすると保存される (戻る操作で自動保存する仕様のため)ことを、ペネトレーションテスト(後述)で見つけました。 現在は、共有メニューから画面を開く時にだけ、アプリのメモリ内で乱数の合言葉(16バイト)を発行して渡し、合言葉の無い 遷移では必ず「このリンクを保存しますか?」の確認を挟みます。合言葉は端末のメモリの外に出ないため、外部のページや アプリは知り得ません(ブックマークレットと同じ考え方を、Androidの画面遷移に当てはめたものです)。 - クリックジャッキング:
frame-ancestors 'none'とX-Frame-Options: DENYで、他のサイトの 枠(iframe)の中に表示させません。
その他: SSRF・オープンリダイレクト
- SSRF: サイトのアイコンの取得では、利用者が指定したURLへサーバーから接続します。内部のネットワーク
(Google Cloudのメタデータサーバー等)へ接続させられないよう、接続する直前に名前解決したIPアドレスを検査し
(プライベート・ループバック・リンクローカル・IPv4射影のIPv6に加え、中にIPv4のアドレスを含められる6to4・Teredoも拒否)、
リダイレクト先も同じく検査します。
http://2130706433/・http://0x7f.1/のような10進・16進表記の アドレスも、URLの解釈の時点で通常の表記に直してから検査します。スキーム (http・https)・ポート(80・443)・応答のサイズ・時間にも上限を設け、単体テストで確認しています。 - オープンリダイレクト: サインイン後の戻り先は、アプリの中のメモリにだけ持ち、URLのパラメータからは 受け取りません。外部のサイトへ転送させられることはありません。
Androidアプリの脆弱性対策(OWASP MASVS)とペネトレーションテスト
Android版については、モバイルアプリのセキュリティ要件をまとめたOWASP MASVS (Mobile Application Security Verification Standard)の観点ごとにテストケースを作り、攻撃者の立場で ペネトレーションテストを行いました(2026-10-02、開発者自身による実施)。Google Playから配信された本番のアプリと 実機(Android 16)を使い、次の3つの方法で確かめています。
- 静的解析: 本番ビルドのマニフェスト(外部から呼べる画面・権限・バックアップの設定)、ソースコード、 依存パッケージの既知の脆弱性
- 動的テスト: adbから細工したディープリンク・共有のデータ(
javascript:・intent:・file:のURL等)を送る、アプリのデータ領域・バックアップ・ログから情報を取り出せないかを試す - サーバーのルールの攻撃テスト: Firestoreのエミュレータ(実在のプロジェクトには接続しない)で、 他人へのなりすまし・全利用者の横断的な読み取り・運用の設定の書き換え・不正な形式の書き込みを試す (ルールの自動テストとして常に実行)
重大な脆弱性は見つかりませんでした。見つかった改善点(外部のディープリンクからの保存、使っていない権限の要求、 サーバーから接続しない宛先の追加、エラーログの形式の制限)は、その日のうちに修正しています。
MASVSの各観点への対応
| 観点 | このアプリでの対応 |
|---|---|
| STORAGE(データの保存) | データはアプリ専用の領域にだけ置き、他のアプリから読める外部ストレージは使いません。
Googleドライブへのバックアップと機種変更時の移行の対象から全て外し(allowBackup="false"と全領域を除外する
dataExtractionRules)、adbからもデータを取り出せないことを本番のアプリで確認しました。暗号化の鍵(DEK)は
Android Keystoreの鍵で暗号化して保存します。ログ(logcat)に本文・トークン・メールアドレスが出ないことも確認しています。 |
| CRYPTO(暗号) | 乱数はOSの暗号論的乱数生成器、暗号はAES-256-GCM(IVは毎回12バイトの乱数)、鍵の導出は PBKDF2-HMAC-SHA256の60万回です(前述)。署名鍵・設定ファイル等の秘密はリポジトリに含めず、 履歴を含めて混入が無いことを確認しました。 |
| AUTH(認証・認可) | Googleサインインは、ブラウザ経由のリダイレクトではなくOSのアカウント選択でIDトークンを 受け取るため、認可コードを他のアプリに横取りされる経路がありません。データの読み書きはFirestoreのルールで本人に限り、 利用者ごとの上限はサーバー側で計算します(端末から上限を書き換えても効きません)。 |
| NETWORK(通信) | 通信はHTTPSだけで、平文(http)の通信は許可していません(targetSdk 36の既定)。 アプリに埋め込むFirebaseのAPIキーは、使えるAPIをFirestore・認証等の必要なものだけに制限しています。 |
| PLATFORM(OSとの連携) | 外部から呼べる画面は、起動・ディープリンクと共有の受け取りの2つだけです。受け取るURLは http・httpsで長さの上限以内のものに限り、開く直前にもスキームを検証します。共有メニュー以外からの保存には確認を挟みます (前述のCSRF)。WebViewは使わず、外部のページはブラウザ(カスタムタブ)で開きます。 React Nativeの既定で入る、使っていない権限(他のアプリの上に重ねて表示、外部ストレージの読み書き)は要求しないように しました(バージョン1.1.5から)。 |
| CODE(コードの品質) | 本番のビルドはデバッグ可能にしていません。URL・タイトル・メモ等の入力は、アプリとサーバーの
両方で長さの上限を設けています。依存パッケージはnpm auditで確認し、端末で動く部分に重大な脆弱性が無いことを
確かめています。 |
公開の基準による再確認(2026-10-16)
機能の追加(AIエージェントのきせかえ・おやつ・本など)とApp Checkの強制の後、次の3つの方法で改めて確認しました (開発者自身による実施。本番の環境への書き込み・負荷をかける試験は行っていません)。
- MobSF(Mobile Security Framework)による静的解析: Google Playから配信された本番のAPK(バージョン1.3.3)を 解析し、指摘を1件ずつ「対応が必要/誤検知/受容」に仕分けました。コードの指摘はすべてライブラリ(androidx・OkHttp・ Expo等)のもので、アプリ自身のコードからの指摘はありませんでした(難読化の対応表で出どころを特定)。 対応が必要な脆弱性は無く、検討事項として最低対応バージョン(Android 7.0)の引き上げを残しています。
- OWASP Mobile Top 10(2024年版)の10項目: 認証情報の扱い、サプライチェーン、 認証・認可、入力・出力の検証、通信、プライバシー、バイナリの保護、設定の誤り、データの保存、暗号の観点で、 ソースコード・Firestore/Storageのルール・Cloud Functions・依存パッケージを確認しました。
- NIST SP 800-163 Rev.1(モバイルアプリのセキュリティの審査): 同文書が審査の要件として 挙げるNIAPの機能要件(プラットフォームの資源へのアクセス、暗号の鍵・処理、機密データの暗号化、HTTPS・TLS、 インストールと更新の完全性、資格情報の保存、第三者のライブラリの利用、個人情報の送信の同意など20項目)ごとに確認しました。
端末で動くアプリには新たな重大な脆弱性はありませんでした。一方、サーバー側(Cloud Functions)の依存ライブラリで、 サイトのアイコンの取得に使う画像の処理ライブラリ(sharp)に、SVGの読み込みの既知の脆弱性(高)が見つかりました。 利用者の指定したサイトの画像を読む処理のため、同日にライブラリを修正版へ更新し、SVGの読み込み自体も止めて デプロイしました(工夫している点の8)。開発・ビルドの道具の依存に残る既知の脆弱性は、 配布するアプリに含まれないことを確かめたうえで、定期の保守で更新します。
データ保管・バックアップ・リストア設計と障害対応
ここまでは「漏えい・不正アクセス」への対策(機密性)が中心でしたが、ここでは 「誤削除・障害・災害によるデータ損失」への対策(可用性・完全性)を扱います。 保管設計、バックアップ・リストア設計、そして障害発生時に何をどこで確認するかという ログ運用について記載します。
保管設計: データはどこに、どう冗長化されているか
正のデータストアはCloud Firestore(Google管理)です。Firestore Native modeは、 データベース作成時に選んだロケーション(リージョン型/マルチリージョン型)に応じて、 Google側のインフラが自動的に複数ゾーン(リージョン型の場合)または複数リージョン (マルチリージョン型の場合)へ同期レプリケーションします。これは開発者側で追加の構成をせずとも Firestore自体が備える冗長性で、単一データセンター障害でデータが失われることはまずありません。
クライアント側(AsyncStorage)にも復号済みのローカルコピーが存在しますが、これはオフライン
閲覧・表示用のキャッシュであり、永続的なバックアップとしては扱っていません。端末の紛失や
アプリの再インストールで失われる前提のデータです。Android版は、このキャッシュ・サインインの情報・
DEKのキャッシュを端末のバックアップや機種変更時の転送の対象から外しています
(android:allowBackup="false"と、Android 12以降向けのdataExtractionRulesで
全領域を除外)。復号済みのデータがクラウドのバックアップに載ることを防ぐためで、機種変更後はサインインと
パスフレーズの入力でFirestoreから復元します。
想定する損失シナリオと現状の対応
| シナリオ | 現状の対応 |
|---|---|
| 誤ってリンク・タスク・メモを削除/上書き編集してしまった | 削除の直後は、画面の下に8秒間出る「元に戻す」で直前の1件を戻せる(1.2.5から。版数を進めて送り直すため、 他の端末にも戻る)。それ以外(上書き編集・時間が経った削除)はアプリ内で戻せないが、後述のPITR(直近7日)・ 日次バックアップ(30日保持)から、運用手順により復旧可能(下記参照)。 |
| 運用ミス・スクリプトの不具合等でFirestoreのデータが意図せず失われた | 同上(PITR・日次バックアップは2026-09-26に有効化済み)。 |
| 端末を紛失・機種変更した | Googleサインイン+クラウド同期により別端末で復元可能(パスフレーズが分かる場合)。 未サインイン時にローカルのみへ保存したデータは同期対象外のため端末とともに失われる。 |
| 暗号化パスフレーズを忘れた | ロックを解除済みのAndroid端末が1台あれば、端末の本人確認の後に今のパスフレーズなしで変更できる(1.5.1から。 前述)。そのような端末が無い場合は、データ自体はFirestore上に残っていても復号不能。 サーバー側に復旧経路を持たないE2E暗号化の設計上、意図した挙動(詳細は後述「E2E暗号化とバックアップの限界」)。 |
| 手元に明示的なバックアップファイルを残しておきたい | 設定画面のインポート・エクスポート機能で手動バックアップ・復元が可能(実装済み、詳細後述)。 |
サーバー側バックアップ・リストア設計(PITR・スケジュールバックアップ)
Firestoreには2種類のバックアップ機構があり、運用手順を整備したうえで、 本番のデータベースで有効化しています。あわせて、 データベース自体の誤削除を防ぐDelete Protectionも有効にしています。
- Point-in-Time Recovery(PITR): 直近7日以内の任意の時点へ データベース全体を巻き戻せます。誤削除・誤更新からの復旧に有効です。
- スケジュールバックアップ: 日次で自動的にバックアップを作成し、30日間 保持しています(PITRの7日を超えた障害にも対応)。
Firestoreのバックアップは既存データベースへの直接上書き復元ができないため、 「新しいデータベースとして復元してから、内容を確認し本番へ反映する」という2段階の設計になります。
クライアント側バックアップ(インポート・エクスポート機能、実装済み)
設定画面から、保存済みのリンク・タスク・メモ・習慣(実施の記録を含む)をJSONファイルとして手動でエクスポート・インポートできます。ローカルの復号済みリポジトリ全件を対象に、Androidは Storage Access Framework、Web版はFile System Access API(非対応ブラウザは通常のダウンロードに フォールバック)でファイルを保存します。インポートは「追記」方式で、リンクはURL一致で重複を除外し、 タスク・メモは既存(またはファイル内で先に出た項目)と同じタイトルのものを取り込みません(2026-10-12から。 同じタイトルのタスク・メモは登録できない仕様のため)。古い形式(リンクのみ、リンクとタスクのみ)のファイルも読み込めます。このアプリのJSON形式に加えてChromeのブックマークHTML形式(リンクのみ)も 受け付けます。画像メモは写真(Storageから取得して復号したもの)もBase64でファイルに含め、取り込み時に取り込む アカウントの鍵で暗号化し直して置きます。取り込みは100件ごとにまとめて端末へ保存し、Firestoreへの送信も短い間の書き込みをまとめて 1回で行うため、リンク約1,000件・タスク約2,000件でも約1分で完了します。
E2E暗号化とバックアップ・リストアの根本的な限界
PITR・スケジュールバックアップ・インポート/エクスポートはいずれも「暗号化されたままのデータ、 または復号済みのローカルデータ」を保護する仕組みであり、暗号化パスフレーズの紛失による データ喪失そのものは防げません。これはサーバー側に復旧経路(パスフレーズのリセット手段や バックドア)を一切持たないゼロ知識設計の帰結であり、意図した仕様です。1.5.1のパスフレーズの変更 (前述)も、DEKを既に持っているロック解除済みの端末の中だけで行うもので、 サーバーに復旧経路を加えたものではありません。そのような端末が無い場合、バックアップ設計を どれだけ強化しても、この一点だけは技術的にトレードオフの外に出せない前提として明記しておきます。
障害発生時のログ運用(形式・保管場所・解析手順)
何を記録し何を絶対に記録しないかという方針は前述の 「ログに平文を残さない設計」の通りです。ここでは、実際に障害・不具合が 起きたときに、どの形式のログが・どこに・どれくらいの期間保管され、解析時にどこを見ればよいかを 整理します。
| ログの種類 | 形式・保管場所 | 解析時に見る場所 |
|---|---|---|
| クライアント(アプリ本体)のエラー・警告 |
Firestoreルート直下errorLogsコレクション。1件1ドキュメントで
{ uid, context, message, level: 'fatal'|'error'|'warn', platform: 'android'|'ios'|'web',
createdAt, errorId? }という固定フィールドのみ(hasOnlyで強制)。クライアントからの
読み取りは不可(createのみ許可)。relayErrorLogsが記録ごとに削除の予定日
(expireAt、記録から180日後)を付け、TTLポリシーで自動的に削除する。アカウント削除後も
この期間は保持される(削除後の問い合わせに対応するため。プライバシーポリシーに明記)。
本番のfatal・errorはCloud Loggingへも橋渡しし、アラートの対象にする
(Cloud Loggingの保持は30日)。
|
アラートのメールのlogId(ドキュメントID)、または問い合わせのエラーIDで
Firebase Console → Firestore Database → errorLogsを検索。
uidフィールドで対象ユーザーに絞り込み。contextで発生箇所、
messageでエラー内容、level/platformで種別・端末を確認。
|
| Cloud Functions(suggestLinkTagsBatch等、実体はCloud Run) |
標準出力・例外が自動的にCloud Loggingへ構造化ログとして収集される(severityが
ERRORのエントリにスタックトレースを含む)。AIの使用量(件数・トークン数。送った内容は含めない)と、
App Checkで検証できなかった呼び出し(関数名のみ)も記録する。保持期間はCloud Loggingの既定
(変更していなければ30日)。
|
Google Cloud Console → Logging → ログ エクスプローラで、HTTPステータス・レイテンシ・エラー詳細を確認。 |
| ai-link-collector(Cloud Run Jobs、毎日のバッチ) | 標準出力が自動的にCloud Loggingへ収集される。キーワード文字列・収集記事の内容は 一切出力しない(意図的な設計)。 | ログ エクスプローラで、対象のジョブ・利用者・実行日時に絞り込んで確認。 |
| 接続元IPアドレス(不正アクセス調査) | クライアントからは送信・記録していない(自己申告IPは信頼できないため)。Web版の接続制限で
拒否した接続は、checkWebAccessがサーバー側で取得したIPをCloud Loggingに記録する
(30日保持)。それ以外の調査には、Google Cloudの監査ログを使う。 |
調査の手順に沿って、Cloud Consoleの監査ログ・ログ エクスプローラで確認。 |
現状の既知の限界として、未サインイン状態で発生したエラーはFirestoreへ書き込めないため ローカルconsole出力のみに残る(サインイン前の不具合はリモートから追えない)点があります。 障害の検知は、2026-09-27にCloud Monitoringの自動アラート(サポート用アカウントへのメール、 スマートフォンへの通知)を導入し、手動でのログ確認に依存しない形にしました。
他アプリとの差別化ポイント
- 本文をエンドツーエンドで暗号化するリンク管理アプリは珍しい。 一般的な「あとで読む」系アプリはサーバー側で平文のまま検索・全文インデックスを提供することが多く、 運営者がデータを読める前提の設計がほとんどです。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回)の根拠。
-
OWASP Top 10:2025
Webアプリケーションの代表的なセキュリティリスク10項目。脆弱性対策の点検の観点に使用。
-
OWASP MASVS(Mobile Application Security Verification Standard)
モバイルアプリのセキュリティ要件の標準。Android版のペネトレーションテストの観点に使用。
-
OWASP Mobile Top 10(2024)
モバイルアプリの代表的なセキュリティリスク10項目。2026-10-16の再確認の観点に使用。
-
NIST SP 800-163 Rev. 1 - Vetting the Security of Mobile Applications
モバイルアプリのセキュリティの審査の手順と要件(NIAPの機能要件)。2026-10-16の再確認の観点に使用。
-
MobSF(Mobile Security Framework)
モバイルアプリの自動解析ツール。本番のAPKの静的解析に使用。
-
OWASP Cross-Site Request Forgery Prevention Cheat Sheet
CSRF対策(トークン方式等)。ブックマークレットの合言葉の設計の根拠。
-
OWASP Cross Site Scripting Prevention Cheat Sheet
XSS対策(出力のエスケープ・危険なURLスキームの拒否等)。
-
OWASP SQL Injection Prevention Cheat Sheet
SQLインジェクション対策(クエリと入力の分離)。
-
OWASP Server-Side Request Forgery Prevention Cheat Sheet
SSRF対策(宛先のIPアドレスの検査・リダイレクトの扱い)。サイトのアイコンの取得に適用。
-
MDN - コンテンツセキュリティポリシー(CSP)
Web版・公開サイトに設定しているCSPの仕様。
-
IPA 安全なウェブサイトの作り方
SQLインジェクション・XSS・CSRF等の代表的な脆弱性と対策(日本語)。
-
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 ドキュメント
アプリ基盤(Expo・expo-router)のドキュメント。
-
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での鍵導出高速化に使用。