Этап 6: закалка — лимиты ADR-021, аудит модели угроз, сверка документов

Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин
на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After;
X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок
отменял бы лимит на IP; карты вёдер ограничены поколениями.

Аудит нашёл то, что пропустили пять раундов ревью:
ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md.
Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare.
ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная
сессия продолжала получать сообщения.
ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший
офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа —
вопреки обещанию storage.md о повторной попытке после получения keyId.
ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой
комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих
запросах, включая выход из комнаты: 116 отказов за прогон стало нулём.
ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version,
403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода,
kdf как оракул после повышения цели KDF.

Модель угроз пополнена тем, что действительно видит оператор: push-подписки
лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это
произвольное уведомление на экране блокировки.

README приведён к v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
2026-08-23 06:22:52 +03:00
co-authored by Claude Opus 5
parent 0c878477d2
commit db45978b16
45 changed files with 1522 additions and 282 deletions
+43 -6
View File
@@ -80,28 +80,65 @@ func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error
// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти
// или ключ которого не расшифровать. При logoutOthers в той же транзакции
// удаляются все сессии пользователя, кроме keep — текущей.
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) error {
//
// Первое значение — устройства, к которым были привязаны удалённые сессии:
// их потоки событий закрывает обработчик. Поток проверяет сессию только
// при подключении, поэтому отозванная иначе продолжала бы получать
// конверты до обрыва соединения (ADR-058).
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) ([]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`,
cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
var revoked []string
if logoutOthers {
revoked, err = revokedDevices(ctx, tx, nick, keep)
if err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: смена пароля: %w", err)
return nil, fmt.Errorf("store: смена пароля: %w", err)
}
return nil
return revoked, nil
}
// revokedDevices — устройства завершаемых сессий, кроме устройства текущей:
// её оставляют, и закрывать её поток незачем.
func revokedDevices(ctx context.Context, tx *sql.Tx, nick string, keep []byte) ([]string, error) {
rows, err := tx.QueryContext(ctx, `
SELECT DISTINCT device_id FROM sessions
WHERE nick = ? AND token_hash <> ? AND device_id IS NOT NULL
AND device_id NOT IN (SELECT device_id FROM sessions WHERE token_hash = ? AND device_id IS NOT NULL)
ORDER BY device_id`, nick, keep, keep)
if err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: устройства завершённых сессий: %w", err)
}
return out, nil
}
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство,