Files
bare/docs/storage.md
T
mayatnikovandClaude Opus 5 05586218e1 Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
2026-08-22 22:18:13 +03:00

153 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Хранение
## Сервер — SQLite
Режим: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`. Версия схемы — `PRAGMA user_version`; миграции — `internal/store/migrations/NNN_*.sql`, встроены через `embed`, применяются по порядку при старте, каждая в транзакции. Время — миллисекунды Unix в `INTEGER`.
### Миграция 001
```sql
CREATE TABLE users (
nick TEXT PRIMARY KEY,
auth_hash BLOB NOT NULL, -- argon2id(authKey), 32 байта
auth_salt BLOB NOT NULL, -- 16 байт
auth_params TEXT NOT NULL, -- "argon2id,m=19456,t=2,p=1"
public_key TEXT NOT NULL, -- JWK, JSON
key_blob TEXT NOT NULL, -- непрозрачный JSON клиента
created_at INTEGER NOT NULL
);
CREATE TABLE devices (
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
push_subscription TEXT, -- JSON PushSubscription или NULL
push_pending INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX devices_nick ON devices(nick);
CREATE TABLE sessions (
token_hash BLOB PRIMARY KEY, -- SHA-256(токен)
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
device_id TEXT REFERENCES devices(id) ON DELETE CASCADE, -- NULL до POST /api/devices
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX sessions_nick ON sessions(nick);
CREATE TABLE contacts (
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
peer TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (nick, peer)
);
CREATE TABLE rooms (
id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент
name TEXT NOT NULL,
owner TEXT NOT NULL REFERENCES users(nick),
created_at INTEGER NOT NULL
);
CREATE TABLE room_members (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
joined_at INTEGER NOT NULL,
PRIMARY KEY (room_id, nick)
);
CREATE INDEX room_members_nick ON room_members(nick);
CREATE TABLE room_keys (
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE,
key_id TEXT NOT NULL,
sender TEXT NOT NULL, -- кто завернул
iv TEXT NOT NULL,
ct TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (room_id, nick, key_id)
);
CREATE TABLE queue (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
msg_id TEXT NOT NULL,
envelope TEXT NOT NULL, -- готовый JSON Envelope
created_at INTEGER NOT NULL,
PRIMARY KEY (device_id, msg_id)
);
CREATE INDEX queue_created ON queue(created_at);
```
### Миграция 002
```sql
ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;
```
Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`.
Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`.
Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен.
Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает его комнаты — убирает членство и ключи, передаёт владение или удаляет опустевшую комнату, ставит `needs_rekey` там, где участники остались (ADR-041), — остальное уносит каскад.
### Фоновая чистка, раз в час
```sql
DELETE FROM queue WHERE created_at < :now - 30 дней;
DELETE FROM devices WHERE last_seen < :now - 90 дней;
DELETE FROM sessions WHERE expires_at < :now;
-- room_keys: оставить два последних key_id на комнату
```
### Чего в базе нет
Истории сообщений, плейнтекста, паролей, ключей в открытом виде, IP-адресов, логов доставки.
## Клиент — IndexedDB
База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029).
```
meta key: string → value
deviceId, nick, publicKey (JWK), fingerprint,
privateKey (CryptoKey ECDH, non-extractable),
accountSecret (CryptoKey HKDF, non-extractable),
notificationsAsked (bool), installBannerDismissed (bool)
chats key: id // "dm:<peer>" | "room:<roomId>"
{id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[],
lastId: ULID|null, lastReadId: ULID|null, unread: number, hidden: bool}
messages key: id (ULID)
index "chat": [chatId, id]
{id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed",
error?: string, // текст отказа у failed
undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope}
roomKeys key: [roomId, keyId]
{roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt}
// receivedAt строго больше receivedAt всех прежних ключей той же комнаты;
// текущий ключ — последний по нему, то есть в порядке получения (ADR-042)
peers key: nick
{nick, publicKey: JWK, fingerprint, firstSeen,
pending: {publicKey, fingerprint, seenAt} | null} // новый ключ, ждущий подтверждения
```
Правила:
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`.
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`.
- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202``sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx``failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id``failed` с текстом про часы (ADR-036).
- `unread` и `lastReadId` — локальные, на сервер не уходят.
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
- При старте: `navigator.storage.persist()`; в настройках — `storage.estimate()`.
## Экспорт `.bare`
Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.