Основной вес несут две мысли. Ваш аккаунт — настоящая сессия аутентификации, а не строка, с паролем из которой мы сверяем ввод. И что вам позволено читать, решает база данных, а не приложение — так что изменённый клиент или тот, кто обращается к API напрямую с публичным ключом, получает ровно те же ответы, что и приложение.
Аккаунты: bcrypt, настоящая сессия и мост к профилю
Личность живёт в Supabase Auth. Пароли хешируются bcrypt на сервере; приложение никогда не видит хеша пароля, и самодельного сравнения нигде на пути входа нет. Вход выдаёт JWT, ограниченный вами, а колонка в строке вашего профиля (auth_uid) — единственная связь между пользователем аутентификации и профилем, и именно она делает выразимыми все политики ниже.
Войти можно по адресу почты и паролю или через Google с помощью Credential Manager в Android, который передаёт Supabase настоящий Google ID token для обмена. Выход на всех устройствах убивает сессию глобально.
Осталась унаследованная колонка PIN — со времён до Supabase Auth. Её никто не читает, новые аккаунты создаются с пустым значением, и она стоит в очереди на удаление. Упомянута здесь только потому, что существует и вы бы её нашли.
Passkey: проверка на сервере и защита от повтора
Korat поддерживает passkey — пару ключей, созданную в защищённом аппаратном хранилище телефона, разблокируемую отпечатком или лицом и никогда не покидающую устройство. Доверяющая сторона — koratland.com, связанная с приложением Android через assetlinks.json, а проверка выполняется в Cloudflare Worker, а не библиотекой:
- challenge одноразовый и живёт пять минут, а строка удаляется при использовании
rpIdHashв данных аутентификатора обязан совпадать с SHA-256 отkoratland.com- флаг присутствия пользователя обязан быть выставлен
- подпись проверяется через WebCrypto — ECDSA P-256 или RS256
- счётчик подписей обязан расти; повторившийся или уменьшившийся счётчик отвергается как повтор
Регистрация требует существующей сессии и берёт личность вызывающего из его bearer-токена, а не из идентификатора в теле запроса, — иначе кто угодно мог бы зарегистрировать passkey на чужой аккаунт. У хранимой строки учётных данных нет политики UPDATE вообще, поэтому даже владелец не может переписать свой открытый ключ или счётчик подписей; переименование идёт через функцию, которая может изменить только название.
Одно осознанное упущение: аттестация не проверяется. Korat не выясняет, какой производитель сделал аутентификатор, поэтому ничто здесь не следует читать как утверждение, подтверждённое аппаратно.
Честный статус. *Регистрация* passkey проверена и работает. *Вход* по passkey ещё ни разу не проходили от начала до конца, а в веб-версии поддержки passkey нет вовсе — это функция Android в процессе выкатки. Ежедневно используются почта с паролем и вход через Google.
Защита на уровне строк: отвечает база, а не приложение
Защита на уровне строк включена по всей базе данных. Политики написаны через четыре функции, и почти каждое правило в Korat — одна из них:
- app_uid()
- Идентификатор профиля вызывающего или 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). Дочерняя таблица должна читаться ровно настолько же, насколько родительская, поэтому комментарии и реакции делегируют посту, а не заводят собственное представление о том, кому можно. И функция-definer выполняется от владельца, а значит, обязана проверять права сама.
Чего RLS не умеет и что её заменяет
Защита на уровне строк умеет отвечать на вопрос *кто обращается*. Она никогда не ответит на вопрос *нёс ли этот запрос действительный токен* — QR стола, ссылку, которой поделились, приглашение. Поэтому каждый путь в Korat, закрытый токеном, — это функция SECURITY DEFINER, проверяющая токен сама, а таблица остаётся закрытой.
Вход за стол. Тот, кто собирается отсканировать QR, не хозяин сессии и не сотрудник, поэтому таблицу сессий за столами он не может прочитать вообще — даже чтобы посмотреть токен. join_session_by_token сверяет токен на сервере, делает первого сканировавшего хозяином, добавляет участника и не возвращает ровным счётом ничего, когда токен неверен, вместо сообщения, по которому можно было бы сузить перебор.
Ссылки, которыми делятся. resolve_share возвращает тип и объект ссылки и больше ничего — ни автора, ни счётчик переходов. Подсчёт перехода идёт через record_share_click, поэтому любого можно посчитать без всякого права на вставку в аналитическую таблицу; политики INSERT там намеренно нет ни у кого.
Деньги. Оформление заказа целиком выполняется на сервере. Он сам считает каждую строку и читает стоимость доставки из собственной записи заведения, игнорируя присланное клиентом, — потому что стоимость, которую задаёт клиент, — это скидка, которую он выписывает себе сам.
Контакты, устройства и вход, которого вы не совершали
Код подтверждения для запасного адреса почты создаёт функция, вызвать которую может только сервисная роль. Хранится только SHA-256 кода, он истекает через десять минут, допускает пять попыток и ограничен одним запросом в минуту. Worker, отправляющий письмо, никогда не возвращает код обратно в приложение.
Отметить свой контакт подтверждённым нельзя. Триггер базы принудительно ставит флаг подтверждения в ложь при любой записи со стороны клиента, а поставить истину может только функция-definer, которая действительно сверила код. Это выглядит паранойей, пока не додумаешь: без этого добавление своего адреса к чужому аккаунту и нажатие «забыли пароль» — полный захват. По той же причине контакт должен быть подтверждён, прежде чем станет основным: именно на основной адрес система пишет.
Каждое устройство, где выполнен вход, перечислено в настройках, и каждое можно отозвать отдельно. У таблицы устройств нет ни политики вставки, ни обновления, ни удаления, потому что только что отозванное устройство ещё держит действительный токен и иначе смогло бы снять собственный отзыв. Отзыв устройства удаляет и лежащую под ним сессию, так что refresh-токен умирает вместе с ним.
Вход с нового устройства записывает уведомление в приложении в той же транзакции, что создаёт строку устройства, — потерять его нельзя, — и вдобавок пишет вам письмо: только на подтверждённые контакты, потому что неподтверждённый адрес может принадлежать другому человеку. Письмо уходит не более одного раза на устройство и только для устройства, созданного за последний час, чтобы эндпоинт нельзя было превратить в способ завалить владельца аккаунта почтой.
Предел отзыва, названный прямо. Уже выданный access-токен живёт около часа, и отозвать его на лету нельзя. Отозванное устройство, находящееся офлайн, продолжает работать, пока его токен не истечёт. Такова природа JWT, а не ошибка, — и именно поэтому существует список устройств, а не утверждение, что отзыв мгновенный.
Что вы можете сделать со своим аккаунтом
Удаление аккаунта делается самостоятельно, в настройках — никому писать не нужно. Это записанный и отменяемый *запрос*, а не кнопка, которую вы нажимаете в два часа ночи и уже не можете отменить. Статус запроса виден в приложении. privacy@koratland.com остаётся для остальных прав по PDPA: доступ, исправление, переносимость и отзыв согласия.
Жалобы охватывают и материалы, и людей — профили, посты, комментарии, истории, сообщения и страницы заведений. Функция, принимающая жалобу, — SECURITY INVOKER, единственная такая во всей административной части. Работая от имени владельца, она *видела бы* скрытые и приватные посты и отвечала бы по ним иначе, а это превращает кнопку жалобы в оракул о материалах, которые вам видеть не положено. Работая от имени вызывающего, «такого поста нет» и «этот пост вам не виден» сливаются в один ответ — и это честно, потому что с места жалующегося это одно и то же. Ограничение по частоте живёт в триггере, а не в приложении.
Чего Korat не делает
Ради этого списка остальную страницу и стоит читать. Всё здесь правдиво на сегодня.
- Ничто не шифруется сквозным шифрованием. Сообщения, файлы и звонки защищены в канале через TLS, а в хранении — защитой на уровне строк. Сервер может их прочитать. У Korat нет ключей на устройствах и нет управления ключами, и его не стоит выбирать для того, что этого требует.
- Шифрования при хранении на уровне приложения нет сверх того, что даёт управляемая база данных.
- Ни один документ, удостоверяющий личность, никогда не проверялся. eKYC нет. Нигде в Korat не написано «личность подтверждена», а значки доверия вместо этого сообщают, какие есть доказательства.
- Телефонные номера подтвердить нельзя — SMS-провайдер не подключён, и эндпоинт говорит об этом прямо, а не притворяется.
- Загруженные файлы читаются по прямой ссылке кем угодно. Аватарки, изображения постов и изображения из чатов лежат в публичных хранилищах; для записи нужна сессия, но утёкшая ссылка — это файл, который можно скачать. Подписанных URL пока нет.
- Проверки прав в консоли заведения сегодня выполняются на устройстве, при том что та же матрица доступна на сервере как
has_capability(). Считайте права консоли организационной мерой, а не границей безопасности, пока эта выкатка не закончена. - У звонков нет TURN-сервера, и сквозного шифрования у них тоже нет; часть звонков между разными сетями не соединится.
- На текущем тарифе нет резервных копий базы данных, и автоматических тестов тоже нет.
- В веб-версии нет push-уведомлений. Push — возможность приложения для Android; в браузере уведомление видно при открытии вкладки, а не в момент, когда оно приходит.
Если что-то из этого изменится, изменится и страница. Написать абзац про шифрование банковского уровня и пойти дальше было бы проще; так — полезнее. Рассуждения, стоящие за этими решениями, лежат на странице о проекте.