Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним атомарным запросом с проверкой keys[].to против итогового состава; обрезка до двух последних ключей; передача владения по joined_at; события room каждому со своим ключом и room_left удалённым; членство и keyId в проверках сообщения; миграция 002. Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента; предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой сохранённого raw; заворачивание и разворачивание ключей комнаты, включая себе — тем же кодом, без ветвления; расшифровка любым известным keyId; экраны участников, создание комнаты, карточка контакта с двумя отпечатками. ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание, а оно происходит до запроса — создатель обязан привязать ключ к идентификатору, которого по прежнему протоколу ещё не существовало. ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ сервера. Одно подменённое поле в ответе, без сговора и подмены кода. ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец, офлайн в момент выхода участника, не узнавал о долге никогда, и комната навсегда оставалась на ключе, который вышедший знает. ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой комнаты. Приёмка на боевом: комната на троих, добавленный четвёртый читает только новое, вышедший после rekey новых не получает и писать не может, keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device. Подмена public_key в базе даёт у собеседника предупреждение и блокирует отправку — проверено в Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
133 lines
4.4 KiB
Go
133 lines
4.4 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/xmatic-squad/bare/internal/auth"
|
|
"github.com/xmatic-squad/bare/internal/store"
|
|
)
|
|
|
|
// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент
|
|
// (ADR-017), сервер только проверяет форму и принадлежность.
|
|
type deviceID struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// POST /api/devices — регистрация устройства. Уже заведённое своё —
|
|
// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id.
|
|
func (s *server) createDevice(w http.ResponseWriter, r *http.Request) {
|
|
var in deviceID
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
if !validID(in.ID) {
|
|
Invalid(w, "id", "id — не 16 байт base64url")
|
|
return
|
|
}
|
|
sess, _ := auth.From(r)
|
|
created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli())
|
|
if errors.Is(err, store.ErrDeviceTaken) {
|
|
Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if created {
|
|
status = http.StatusCreated
|
|
}
|
|
writeJSON(w, status, deviceID{in.ID})
|
|
}
|
|
|
|
// deviceOut — строка ответа GET /api/devices.
|
|
type deviceOut struct {
|
|
ID string `json:"id"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
LastSeen int64 `json:"lastSeen"`
|
|
HasPush bool `json:"hasPush"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе
|
|
// нет, только факт её наличия.
|
|
func (s *server) devices(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := auth.From(r)
|
|
list, err := s.st.Devices(r.Context(), sess.Nick)
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
out := make([]deviceOut, 0, len(list))
|
|
for _, d := range list {
|
|
out = append(out, deviceOut{
|
|
ID: d.ID,
|
|
CreatedAt: d.CreatedAt,
|
|
LastSeen: d.LastSeen,
|
|
HasPush: d.HasPush,
|
|
Current: d.ID == sess.DeviceID,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// DELETE /api/devices/{id} — удаление устройства: очередь, подписка
|
|
// и сессии уходят каскадом, открытый поток событий закрывается.
|
|
//
|
|
// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего,
|
|
// а отдельного кода на этот случай в протоколе нет (docs/protocol.md).
|
|
func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := auth.From(r)
|
|
id := r.PathValue("id")
|
|
deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick)
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return
|
|
}
|
|
if deleted {
|
|
s.hub.Close(id)
|
|
}
|
|
noContent(w)
|
|
}
|
|
|
|
// device читает X-Device и проверяет, что устройство принадлежит
|
|
// пользователю сессии. Заголовка нет, форма кривая, устройство чужое —
|
|
// всё это 403 unknown_device (docs/protocol.md, «Общие правила»).
|
|
func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
sess, _ := auth.From(r)
|
|
id := r.Header.Get("X-Device")
|
|
if !validID(id) {
|
|
unknownDevice(w)
|
|
return "", false
|
|
}
|
|
owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick)
|
|
if err != nil {
|
|
s.internal(w, r, err)
|
|
return "", false
|
|
}
|
|
if !owned {
|
|
unknownDevice(w)
|
|
return "", false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// optionalDevice — то же для маршрутов, где заголовок необязателен:
|
|
// он всего лишь просит не возвращать эхо отправившему устройству. Пустой
|
|
// X-Device — пусто, непустой обязан быть своим устройством: правило
|
|
// принадлежности общее для всех маршрутов, где устройство важно
|
|
// (docs/protocol.md, «Общие правила»).
|
|
func (s *server) optionalDevice(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
if r.Header.Get("X-Device") == "" {
|
|
return "", true
|
|
}
|
|
return s.device(w, r)
|
|
}
|
|
|
|
func unknownDevice(w http.ResponseWriter) {
|
|
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
|
|
}
|