两个想法承担了大部分重量。你的账号是一个真实的认证会话,而不是一行我们拿来比对密码的记录。以及决定你能读到什么的是数据库,不是应用——所以一个被改过的客户端,或者拿着公开密钥直接和 API 对话的人,得到的答案和应用完全一样。
账号:bcrypt、真实会话,以及通往资料的一座桥
身份存在 Supabase Auth 里。密码在服务端用 bcrypt 哈希;应用从不接触密码哈希,登录路径上也没有任何自制的比对逻辑。登录产生一个属于你的 JWT,而你资料行上的一个字段(auth_uid)是认证用户与资料行之间唯一的连接——正是它让下面每一条策略得以表达。
你可以用邮箱加密码登录,也可以通过安卓的 Credential Manager 用 Google 登录,后者会把一个真实的 Google ID 令牌交给 Supabase 换取会话。全局登出会在所有地方终止会话。
还有一个遗留的 PIN 字段,来自启用 Supabase Auth 之前。没有任何东西读它,新账号创建时它是空的,而且已排入删除队列。写在这里只是因为它确实存在,你迟早会翻到。
通行密钥:在服务端验证,并带重放防护
Korat 支持通行密钥(passkey)——一对在你手机安全硬件里生成、用指纹或面容解锁、永不离开设备的密钥。依赖方是 koratland.com,通过 assetlinks.json 与安卓应用绑定,而验证跑在一个 Cloudflare Worker 里,不是调库:
- 挑战值单次有效、五分钟过期,用掉即删除该行
- authenticator data 里的
rpIdHash必须等于koratland.com的 SHA-256 - user-present 标志必须被置位
- 签名用 WebCrypto 验证——ECDSA P-256 或 RS256
- 签名计数器必须递增;重复或倒退的计数器按重放拒绝
注册要求已有会话,且调用者的身份取自其 bearer token,绝不取自请求体里的用户 id——否则任何人都能把一枚通行密钥注册到任何人的账号上。存储凭据的那一行完全没有 UPDATE 策略,所以连它的所有者也无法改写自己的公钥或签名计数器;重命名通行密钥走的是一个只能改标签的函数。
有一处刻意的省略:不验证 attestation。Korat 不检查认证器由哪家厂商制造,所以这里的任何内容都不应被读成硬件背书的声明。
诚实的状态。通行密钥的*注册*已验证可用。通行密钥*登录*尚未端到端跑通,网页版则完全没有通行密钥支持——它是一项处于推进中的安卓功能。日常使用的路径是邮箱加密码,以及 Google 登录。
行级安全:回答问题的是数据库,不是应用
行级安全在整个数据库上启用。策略围绕四个函数书写,而 Korat 里几乎每条规则都是其中之一:
- app_uid()
- 调用者的资料 id,未登录时为 null。每一条“只能读自己那行”策略的基础。
- is_member_of(business_id)
- 对店主、或对该商家持有有效成员记录的人为真。
- has_capability(business_id, capability)
- 在服务端展开角色预设——店主、经理、服务员、厨房——所以数据库从不听信客户端对角色含义的理解。
- can_review(business_id)
- 读取资格视图,让“只有真实顾客能评价”成为数据库的属性,而不是应用给出的一句承诺。
有三条规则的代价高到值得写下来。只从店家视角书写的策略会把顾客排除在外——一次就餐会话、一次呼叫服务员和一笔交易都有两位合法读者,所以它们写成 user_id = app_uid() or is_member_of(business_id)。子表的可读范围必须与父表完全一致,所以评论和点赞把判断委派给帖子,而不是各自持有一套关于谁可以读的看法。以及定义者函数以所有者身份运行,因此它必须自己检查权限。
RLS 做不到的事,以及用什么来替代
行级安全能回答*谁在调用*。它永远无法回答*这个请求是否携带了有效令牌*——桌台二维码、分享链接、一份邀请。所以 Korat 里每一条由令牌把守的路径,都是一个自己检查令牌的 SECURITY DEFINER 函数,而表本身保持关闭。
加入餐桌。正要扫码的人既不是会话的主人也不是店员,所以他根本读不到就餐会话表——连查一下令牌都不行。join_session_by_token 在服务端匹配令牌,把第一个扫码的人设为主人,加入参与者,而令牌不对时什么都不返回,而不是给出一句能被用来缩小猜测范围的提示。
分享链接。resolve_share 只返回链接的类型和目标,别的什么都没有——从不返回是谁分享的,从不返回点击数。记一次点击走 record_share_click,所以任何人都能被计入,却不需要对统计表有任何插入权限;那张表刻意对任何人都没有插入策略。
金额。下单完全跑在服务器上。它自己给每一行定价,并从商家自己那行读取配送费,忽略客户端发来的任何值——因为一笔客户端能设定的费用,就是一张客户端能自己发给自己的折扣券。
联系方式、设备,以及那次不是你的登录
备用邮箱的验证码由一个只有 service role 能调用的函数生成。只存储验证码的 SHA-256,十分钟过期,允许五次尝试,且限流为每分钟一次请求。负责发信的 Worker 从不把验证码回传给应用。
你不能把自己的联系方式标记为已验证。数据库触发器会在任何客户端写入时把已验证标志强制为 false,只有真正核对过验证码的定义者函数才能置位。这看起来偏执,直到你把它推演完:没有它,把你的地址加到别人的账号上再点“忘记密码”,就是一次完整的账号接管。出于同样的理由,一个联系方式必须先验证才能设为主要——主要地址正是系统发信的那个地址。
每一台登录过的设备都列在设置里,可以逐台吊销。设备表完全没有插入、更新或删除策略,因为一台刚被吊销的设备手上还握着有效令牌,否则它就能清掉自己的吊销状态。吊销一台设备会删除底层会话,刷新令牌随之失效。
从新设备登录时,应用内提醒与创建设备行在同一个事务里写入,因此不会丢失,同时也给你发邮件——只发往已验证的联系方式,因为未验证的地址可能属于别人。邮件对每台设备最多发一次,且只针对最近一小时内创建的设备,所以这个端点无法被变成刷屏轰炸账号所有者的工具。
吊销的边界,直说。一枚已经签发出去的访问令牌大约存活一小时,无法在飞行途中召回。一台离线的、已被吊销的设备会一直工作到它的令牌过期。这是 JWT 的本性而不是 bug,也正是我们提供设备列表、而不是声称“吊销即时生效”的原因。
关于你自己的账号,你能做什么
删除账号可以自助完成,就在设置里——不用给谁写邮件。它是一条被记录下来、可以撤销的*请求*,而不是你凌晨两点按下去就没法回头的按钮。它的状态在应用里可以查看。privacy@koratland.com 仍然承接其他 PDPA 权利:查阅、更正、可携带和撤回同意。
举报同时覆盖内容和人——个人主页、帖子、评论、快拍、消息和商家主页。接收举报的那个函数是 SECURITY INVOKER,是整个后台里唯一一个这样的。如果它以数据库所有者的权限运行,它就会*看见*被隐藏的帖子和私密帖子,并对它们给出不同的答复,那会把举报按钮变成一种探测「我无权看到的内容是否存在」的手段。以调用者的权限运行时,「没有这条帖子」和「这条帖子不是你能看的」就塌缩成同一个答复——这是诚实的,因为站在举报者的位置上,那两件事本来就是同一件事。频率限制在触发器里,不在应用里。
Korat 做不到什么
这份清单正是这一页其余部分值得读的理由。下面每一条今天都为真。
- 没有任何东西是端到端加密的。消息、文件和通话在传输中由 TLS 保护,在静态时由行级安全保护。服务器能读到它们。Korat 没有设备密钥、没有密钥管理,凡是需要这些的场景都不该选它。
- 除托管数据库自带的部分外,没有应用层的静态加密。
- 从未核验过任何身份证件。没有 eKYC。Korat 里没有任何地方写着“身份已认证”,信任徽章说的是存在哪些证据。
- 电话号码无法验证——没有接入任何短信服务商,端点会直说这一点而不是假装可以。
- 上传的媒体可以通过 URL 被任何人读取。头像、帖子图片和聊天图片存在公开存储桶里;上传或修改需要会话,但一条泄露的 URL 就是一份可被取走的文件。目前没有签名 URL。
- 商家控制台里的权限检查目前跑在设备上,服务端存在同一套矩阵
has_capability()。在那次推进完成之前,请把控制台权限当作一种组织管理手段,而不是一道安全边界。 - 通话没有 TURN 服务器,也没有端到端加密;部分跨网络的通话会连不上。
- 当前方案下没有数据库备份,也没有自动化测试套件。
- 网页版没有推送通知。推送是安卓应用的能力;在网页上,你会在打开标签页时看到通知,而不是它到达的时候。
如果其中任何一条改变了,这一页会跟着改。写一段关于“银行级加密”的话然后翻篇会容易得多;这样写更有用。