Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
215 lines
8.0 KiB
Go
215 lines
8.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func open(t *testing.T, path string) *Store {
|
|
t.Helper()
|
|
s, err := Open(path)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestMigrateAndRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "bare.db")
|
|
|
|
first := open(t, path)
|
|
want := []string{"001_init.sql", "002_room_needs_rekey.sql"}
|
|
if got := first.Applied(); !equal(got, want) {
|
|
t.Fatalf("применённые миграции: получено %v, ожидалось %v", got, want)
|
|
}
|
|
if got := version(t, first); got != len(want) {
|
|
t.Errorf("user_version: получено %d, ожидалась %d", got, len(want))
|
|
}
|
|
// Все восемь таблиц из docs/storage.md на месте.
|
|
for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} {
|
|
var name string
|
|
err := first.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
|
|
if err != nil {
|
|
t.Errorf("таблица %s: %v", table, err)
|
|
}
|
|
}
|
|
// Внешние ключи включены — иначе каскадные удаления молча не работают.
|
|
var fk int
|
|
if err := first.db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil || fk != 1 {
|
|
t.Errorf("foreign_keys: получено %d (%v), ожидалась 1", fk, err)
|
|
}
|
|
var mode string
|
|
if err := first.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
|
|
t.Errorf("journal_mode: получено %q (%v), ожидался wal", mode, err)
|
|
}
|
|
first.Close()
|
|
|
|
// Повторный старт на той же базе ничего не применяет.
|
|
second := open(t, path)
|
|
if got := second.Applied(); len(got) != 0 {
|
|
t.Errorf("повторный старт применил %v, ожидалось ничего", got)
|
|
}
|
|
if got := version(t, second); got != len(want) {
|
|
t.Errorf("user_version после перезапуска: получено %d, ожидалась %d", got, len(want))
|
|
}
|
|
}
|
|
|
|
func version(t *testing.T, s *Store) int {
|
|
t.Helper()
|
|
var v int
|
|
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
|
t.Fatalf("user_version: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestUsersAndSessions(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := open(t, filepath.Join(t.TempDir(), "bare.db"))
|
|
|
|
u := User{
|
|
Nick: "marta",
|
|
Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"},
|
|
PublicKey: `{"kty":"EC"}`,
|
|
KeyBlob: `{"v":1}`,
|
|
CreatedAt: 1,
|
|
}
|
|
if err := s.CreateUser(ctx, u); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if err := s.CreateUser(ctx, u); !errors.Is(err, ErrNickTaken) {
|
|
t.Errorf("повторный ник: получено %v, ожидалось ErrNickTaken", err)
|
|
}
|
|
if _, err := s.User(ctx, "нет-такого"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("чужой ник: получено %v, ожидалось ErrNotFound", err)
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
live := []byte("token-hash-1")
|
|
other := []byte("token-hash-2")
|
|
if err := s.CreateSession(ctx, live, "marta", now, now+1000); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
if err := s.CreateSession(ctx, other, "marta", now, now+1000); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
sess, err := s.Session(ctx, live, now)
|
|
if err != nil || sess.Nick != "marta" || sess.DeviceID != "" {
|
|
t.Fatalf("Session: %+v, %v", sess, err)
|
|
}
|
|
if _, err := s.Session(ctx, live, now+2000); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err)
|
|
}
|
|
|
|
// Смена пароля с logoutOthers: остаётся только текущая сессия.
|
|
cred := Credential{Hash: []byte("new"), Salt: []byte("salt2"), Params: "argon2id,m=19456,t=2,p=1"}
|
|
if err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live); err != nil {
|
|
t.Fatalf("SetPassword: %v", err)
|
|
}
|
|
if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err)
|
|
}
|
|
if _, err := s.Session(ctx, live, now); err != nil {
|
|
t.Errorf("текущая сессия после logoutOthers: %v", err)
|
|
}
|
|
got, err := s.User(ctx, "marta")
|
|
if err != nil {
|
|
t.Fatalf("User: %v", err)
|
|
}
|
|
if string(got.Cred.Hash) != "new" || got.KeyBlob != `{"v":1,"new":true}` {
|
|
t.Errorf("хеш и блоб: получено %q / %q", got.Cred.Hash, got.KeyBlob)
|
|
}
|
|
|
|
// Удаление пользователя уносит сессии каскадом.
|
|
if _, err := s.DeleteUser(ctx, "marta"); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("сессия после удаления аккаунта: получено %v, ожидалось ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanup(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := open(t, filepath.Join(t.TempDir(), "bare.db"))
|
|
now := time.Now()
|
|
ms := now.UnixMilli()
|
|
day := int64(24 * 60 * 60 * 1000)
|
|
|
|
exec := func(query string, args ...any) {
|
|
t.Helper()
|
|
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
|
|
t.Fatalf("%s: %v", query, err)
|
|
}
|
|
}
|
|
exec(`INSERT INTO users (nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at)
|
|
VALUES ('marta', x'00', x'00', 'argon2id,m=19456,t=2,p=1', '{}', '{}', ?)`, ms)
|
|
exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('old', 'marta', ?, ?)`, ms, ms-100*day)
|
|
exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('new', 'marta', ?, ?)`, ms, ms)
|
|
exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('old', 'cascade', '{}', ?)`, ms)
|
|
exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'stale', '{}', ?)`, ms-40*day)
|
|
exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'fresh', '{}', ?)`, ms)
|
|
exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'01', 'marta', ?, ?)`, ms, ms-day)
|
|
exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'02', 'marta', ?, ?)`, ms, ms+day)
|
|
exec(`INSERT INTO rooms (id, name, owner, created_at) VALUES ('r', 'общая', 'marta', ?)`, ms)
|
|
for i, key := range []string{"k1", "k2", "k3"} {
|
|
exec(`INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at)
|
|
VALUES ('r', 'marta', ?, 'marta', 'iv', 'ct', ?)`, key, ms+int64(i))
|
|
}
|
|
|
|
if err := s.Cleanup(ctx, now); err != nil {
|
|
t.Fatalf("Cleanup: %v", err)
|
|
}
|
|
|
|
if got := ids(t, s, `SELECT id FROM devices ORDER BY id`); !equal(got, []string{"new"}) {
|
|
t.Errorf("устройства: получено %v, ожидалось [new]", got)
|
|
}
|
|
// Очередь устройства 'old' ушла каскадом вместе с ним, 'stale' — по сроку.
|
|
if got := ids(t, s, `SELECT msg_id FROM queue ORDER BY msg_id`); !equal(got, []string{"fresh"}) {
|
|
t.Errorf("очередь: получено %v, ожидалось [fresh]", got)
|
|
}
|
|
if got := ids(t, s, `SELECT hex(token_hash) FROM sessions ORDER BY token_hash`); !equal(got, []string{"02"}) {
|
|
t.Errorf("сессии: получено %v, ожидалось [02]", got)
|
|
}
|
|
if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) {
|
|
t.Errorf("ключи комнат: получено %v, ожидалось [k2 k3]", got)
|
|
}
|
|
}
|
|
|
|
func ids(t *testing.T, s *Store, query string) []string {
|
|
t.Helper()
|
|
rows, err := s.db.Query(query)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", query, err)
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func equal(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|