重さのほとんどを二つの考えが支えています。あなたのアカウントは本物の認証セッションであって、パスワードを突き合わせる行ではありません。 そして何を読んでよいかを決めるのはアプリではなくデータベースです——だから改造したクライアントも、公開鍵で API を直接叩く相手も、アプリとまったく同じ答えを受け取ります。
アカウント——bcrypt、本物のセッション、そしてプロフィールへの橋
identity は Supabase Auth にあります。パスワードはサーバー側で bcrypt によりハッシュ化され、アプリがパスワードハッシュを見ることはなく、ログインの経路のどこにも自作の比較処理はありません。サインインすると自分に紐づく JWT が発行され、プロフィールの行の列(auth_uid)が認証ユーザーとプロフィールを結ぶ唯一のリンクです。以下のすべてのポリシーが表現できるのは、これがあるからです。
メールとパスワード、あるいは Android の Credential Manager 経由の Google でサインインできます。後者は本物の Google ID トークンを Supabase に渡して交換します。すべての端末からサインアウトすると、セッションはグローバルに失効します。
Supabase Auth 以前の名残として、PIN の列が残っています。何もそれを読んでおらず、新しいアカウントは空のまま作られ、削除待ちです。ここに書いたのは、それが存在していて、あなたなら見つけるからです。
パスキー——サーバーで検証し、リプレイを防ぐ
Korat はパスキーに対応しています。端末のセキュアなハードウェアの中で作られ、指紋か顔で解錠され、端末から出ることのない鍵ペアです。リライングパーティは koratland.com で、assetlinks.json を通じて Android アプリに結び付いています。検証はライブラリではなく Cloudflare Worker の中で走ります。
- チャレンジは使い捨てで有効期間は5分、消費された時点で行が削除されます
- authenticator data の
rpIdHashはkoratland.comの SHA-256 と一致しなければなりません - user-present フラグが立っていなければなりません
- 署名は WebCrypto で検証します——ECDSA P-256 または RS256
- 署名カウンターは増加していなければなりません。同じ値や逆行はリプレイとして拒否されます
登録には既存のセッションが必要で、呼び出し元の身元はリクエストの本文にあるユーザー ID からではなく、常にベアラートークンから取ります。そうでなければ、誰でも誰かのアカウントにパスキーを登録できてしまいます。保存された資格情報の行には更新のポリシーが一つもないので、持ち主でさえ自分の公開鍵や署名カウンターを書き換えられません。パスキーの名前の変更は、ラベルだけに触れる関数を通ります。
意図的に省いたものが一つあります。アテステーションは検証していません。 Korat は認証器の製造元を確認しないので、ここにあるものをハードウェアによる保証と読むべきではありません。
正直な現状。 パスキーの*登録*は動作を確認済みです。パスキーの*ログイン*はまだ端から端まで通したことがなく、ウェブアプリにはパスキー対応がまったくありません——展開中の Android の機能です。日常的に使われている経路は、メールとパスワード、そして Google サインインです。
行レベルセキュリティ——答えるのはアプリではなくデータベース
行レベルセキュリティはデータベース全体で有効です。ポリシーは4つの関数の言葉で書かれていて、Korat のほぼすべての規則はそのどれかです。
- app_uid()
- 呼び出し元のプロフィール ID。サインインしていなければ null。すべての「自分の行だけ」ポリシーの土台です。
- is_member_of(business_id)
- オーナー、またはそのビジネスの有効なメンバー行を持つ人に対して真になります。
- has_capability(business_id, capability)
- 役割プリセット——オーナー、マネージャー、ホール、キッチン——をサーバー側で展開するので、役割が何を意味するかについてデータベースがクライアントを信用することはありません。
- can_review(business_id)
- 資格判定のビューを読み、「本当の客だけがレビューできる」ことを、アプリの約束ではなくデータベースの性質にします。
高い授業料を払って学んだ規則が3つあり、書いておく価値があります。店の視点だけで書いたポリシーは客を締め出します——食事のセッションも、スタッフ呼び出しも、取引も、正当な読み手が2人いるので、user_id = app_uid() or is_member_of(business_id) と書きます。子テーブルは親とちょうど同じ範囲まで読めなければなりません。だからコメントといいねは、誰が読めるかについて自前の考えを持たず、投稿に判断を委ねます。そして定義者関数はオーナーとして走るので、権限は自分で確認しなければなりません。
RLS にできないことと、その代わりになるもの
行レベルセキュリティは*誰が呼んでいるか*には答えられます。*このリクエストは有効なトークンを持っていたか*には決して答えられません——テーブルの QR、共有リンク、招待。だから Korat のトークンで守られる経路はすべて、トークン自身を検証する SECURITY DEFINER 関数であり、テーブルは閉じたままです。
テーブルに入る。 QR を読もうとしている人は、セッションのホストでもスタッフでもないので、食事セッションのテーブルを一行も読めません——トークンを引くためでさえ。join_session_by_token がサーバー側でトークンを照合し、最初にスキャンした人をホストにし、参加者を追加します。そしてトークンが違うときは、当てを絞る手がかりになるメッセージではなく、何も返しません。
共有リンク。 resolve_share はリンクの種類と対象だけを返し、それ以外は返しません——誰が共有したかも、クリック数も決して。クリックの計上は record_share_click を通るので、解析用テーブルへの挿入権限を一切持たないまま誰でも数えられます。誰に対しても意図的に挿入ポリシーがありません。
お金。 注文の確定は完全にサーバー上で走ります。すべての行を自分で値付けし、配送料はビジネス自身の行から読み、クライアントが送ってきたものは無視します。クライアントが決められる手数料は、クライアントが自分に出せる割引だからです。
連絡先、端末、そして身に覚えのないサインイン
予備のメールアドレス用の確認コードは、service_role だけが呼べる関数で生成されます。保存されるのはコードの SHA-256 だけで、10分で失効し、試行は5回まで、リクエストは1分に1回までです。メールを送る Worker がコードをアプリに返すことはありません。
自分の連絡先を自分で「確認済み」にすることはできません。データベースのトリガーが、クライアントからの書き込みでは確認済みフラグを強制的に false にし、実際にコードを検証した定義者関数だけがそれを立てられます。用心しすぎに見えますが、たどればわかります。これがなければ、他人のアカウントに自分のアドレスを足して「パスワードを忘れた」を押すことが、完全な乗っ取りになります。同じ理由で、連絡先は主にする前に確認済みでなければなりません——主のアドレスは、システムがメールを送る先だからです。
サインイン中のすべての端末は設定に一覧され、個別に失効させられます。端末のテーブルには挿入・更新・削除のポリシーが一つもありません。失効させられたばかりの端末はまだ有効なトークンを持っていて、さもなければ自分の失効を自分で取り消せてしまうからです。端末を失効させると、その裏のセッションが削除されるので、リフレッシュトークンも一緒に死にます。
新しい端末からのサインインは、端末の行を作るのと同じトランザクションの中でアプリ内の警告を書き込むので、取りこぼしません。メールも送ります——確認済みの連絡先にだけです。未確認のアドレスは他人のものかもしれないからです。メールは1つの端末につき最大1回、しかも直近1時間に作られた端末についてだけ送られるので、このエンドポイントをアカウントの持ち主を埋め尽くす手段に変えることはできません。
失効の限界を、はっきり書いておきます。 すでに発行されたアクセストークンは1時間ほど生きていて、途中で呼び戻すことはできません。オフラインの失効済み端末は、そのトークンが切れるまで動き続けます。これは JWT の性質であってバグではなく、「失効は即時です」と主張する代わりに端末一覧が存在している理由です。
自分のアカウントについてできること
アカウントの削除は設定から自分でできます——誰にもメールを書く必要はありません。それは記録され、取り消せる申請であって、深夜2時に押したら戻れないボタンではありません。申請の状態はアプリの中で確認できます。privacy@koratland.com は、開示・訂正・可搬性・同意の撤回といった他の PDPA 上の権利のために残っています。
通報は人と内容の両方を対象にします——プロフィール、投稿、コメント、ストーリー、メッセージ、店舗ページ。通報を受け取る関数は SECURITY INVOKER で、バックオフィス全体で唯一そうなっています。所有者の権限で走れば、非表示の投稿や非公開の投稿が*見えて*しまい、それらに違う答えを返すことになり、通報ボタンは「見る権限のない内容が存在するか」を探る仕掛けになります。呼び出した人の権限で走るなら、「そんな投稿はありません」と「あなたが見てよい投稿ではありません」は一つの答えに潰れます——通報する人の位置から見れば、その二つは同じことだからです。回数の制限はアプリではなくトリガーにあります。
Korat がやっていないこと
この一覧こそが、このページの残りを読む価値のある理由です。ここに書いてあることはすべて今日の事実です。
- エンドツーエンド暗号化されているものは一つもありません。 メッセージもファイルも通話も、通信中は TLS で、保存時は行レベルセキュリティで守られています。サーバーは中身を読めます。Korat には端末側の鍵も鍵管理もなく、それらが必要な用途に選ぶべきではありません。
- マネージドデータベースが備えるもの以外に、アプリケーション層の保存時暗号化はありません。
- 身分証を確認したことは一度もありません。 eKYC はありません。Korat のどこにも「本人確認済み」とは書いておらず、信頼バッジは代わりにどんな証拠があるかを述べます。
- 電話番号は確認できません — SMS の事業者をつないでおらず、エンドポイントはそのふりをせず、そう答えます。
- アップロードされたメディアは URL を知っていれば誰でも読めます。 アバターも投稿画像もチャット画像も公開バケットにあり、書き込みにはセッションが要りますが、漏れた URL は取得できるファイルです。署名付き URL はまだありません。
- ビジネスコンソールの権限判定は現在、端末側で走ります。 同じマトリクスはサーバー側にも
has_capability()として存在します。その移行が終わるまで、コンソールの権限は組織上の統制として扱ってください。セキュリティの境界としてではなく。 - 通話には TURN サーバーがなく、エンドツーエンド暗号化もされていません。ネットワークの組み合わせによっては接続に失敗します。
- 現在のプランにデータベースのバックアップはなく、自動テストもありません。
- ウェブ版にプッシュ通知はありません。 プッシュは Android アプリの機能です。ウェブでは、通知は届いた瞬間ではなくタブを開いたときに見えます。
そのどれかが変われば、このページも一緒に変わります。銀行並みの暗号化について一段落書いて先へ進むほうが楽ですが、こちらのほうが役に立ちます。