Files
moonwell-core/doc/LauncherAuthorizationContract.md
2026-08-17 20:24:17 +04:00

372 lines
22 KiB
Markdown
Raw Permalink 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.
# Контракт авторизации MoonWell через лаунчер
Статус документа: обязательный контракт для Release-сборок.
Ключевые слова **ДОЛЖЕН**, **НЕЛЬЗЯ**, **СЛЕДУЕТ** и **МОЖЕТ** описывают
обязательность требований.
## 1. Цель и границы доверия
Игровой клиент World of Warcraft 3.3.5a остаётся стандартным SRP6-клиентом,
но вместо постоянного пароля получает короткоживущий одноразовый ticket от
MoonWell Launcher. Наличие или отсутствие полей логина в GlueXML не является
границей безопасности. Решение о допустимости входа всегда принимает
`authserver`.
В поток входят четыре компонента:
1. backend аутентифицирует пользователя и выпускает game ticket;
2. launcher передаёт game account name и ticket только через environment block
нового `Wow.exe`;
3. MoonWell.dll забирает значения до загрузки GlueXML и инициирует обычный SRP;
4. `authserver` использует временные salt/verifier и атомарно погашает ticket
после успешного `CMD_AUTH_LOGON_PROOF`.
## 2. Термины
- **backend account** — учётная запись пользователя в сервисах MoonWell;
- **game account** — имя учётной записи AzerothCore, передаваемое в поле `I`
`CMD_AUTH_LOGON_CHALLENGE`;
- **launcher session** — аутентифицированная сессия launcher с backend;
- **ticket** — одноразовый временный пароль для SRP;
- **ticket generation** — уникальный внутренний идентификатор выпуска ticket,
используемый для отзыва и атомарного погашения;
- **dev account** — явно разрешённая сервером учётная запись с постоянным SRP
verifier для локальной разработки.
## 3. Формат launcher credentials
Launcher передаёт ровно две обязательные переменные:
```text
MOONWELL_LAUNCH_ACCOUNT=<game account name>
MOONWELL_LAUNCH_TICKET=<single-use SRP password>
```
### 3.1 `MOONWELL_LAUNCH_ACCOUNT`
- непустая строка печатного ASCII (`0x21..0x7E`), без пробельных символов;
- не содержит `NUL`, `=` и управляющих символов;
- верхний предел безопасного чтения из environment — 320 байт;
- перед SRP приводится к той же канонической форме, что и имя в AzerothCore,
то есть к ASCII uppercase;
- backend и `authserver` сравнивают каноническое имя, а не display name.
Ограничение 320 байт является только пределом входного буфера MoonWell.dll.
Текущий протокол и этот репозиторий не поддерживают game account такой длины:
`I_len` имеет размер `uint8`, `AuthSession.cpp` принимает challenge лишь с
добавкой до 16 байт, а `AccountMgr` использует `MAX_ACCOUNT_STR = 17`.
Поэтому backend **ДОЛЖЕН** отображать длинную backend account на отдельное
короткое game account name, совместимое с текущим сервером (не более 17 байт).
Расширение этого лимита является отдельным изменением протокола/сервера и не
входит в настоящий контракт.
### 3.2 `MOONWELL_LAUNCH_TICKET`
- ровно 16 ASCII-символов;
- допустимый алфавит: `A-Z` и `0-9`;
- генерируется CSPRNG с равномерным rejection sampling;
- энтропия полного пространства `36^16` — около 82,7 бит;
- срок действия: не более 60 секунд от момента выпуска;
- одноразовый;
- новый выпуск атомарно отзывает предыдущий активный ticket game account.
Ни один компонент **НЕ ДОЛЖЕН** нормализовать, менять регистр или обрезать
ticket. Для SRP ticket используется в точности как получен.
## 4. Контракт backend
Конкретный HTTP route может определяться backend, но операция выпуска имеет
следующую семантику:
```json
{
"launcher_session_id": "opaque launcher session identifier",
"client_build": 12340
}
```
Успешный ответ:
```json
{
"account": "GAMEACCOUNT",
"ticket": "7Q9AV4R2M8ZK3W1P",
"expires_at": "2026-08-16T12:34:56Z"
}
```
Требования к операции:
- запрос разрешён только из действующей аутентифицированной launcher session;
- требуемая пользователю MFA завершается до выпуска ticket;
- backend проверяет право launcher session входить в указанный game account;
- `client_build` должен входить в разрешённый список;
- выпуск и отзыв предыдущей generation выполняются одной транзакцией;
- ответ передаётся только по TLS и содержит `Cache-Control: no-store`;
- ticket не возвращается повторно и не попадает в URL, telemetry, analytics,
tracing, audit payload, exception text или access log;
- rate limit применяется как минимум к пользователю, game account, launcher
session и IP;
- время жизни вычисляется по серверному времени. Часы клиента не являются
источником истины.
Рекомендуемые машинные ошибки: `UNAUTHENTICATED`, `ACCOUNT_NOT_ALLOWED`,
`CLIENT_BUILD_NOT_ALLOWED`, `RATE_LIMITED`, `GAME_ACCOUNT_NOT_COMPATIBLE` и
`INTERNAL_ERROR`. Ошибка не должна содержать ticket или verifier.
## 5. Хранение ticket
Предпочтительный вариант — не хранить открытый ticket. Backend вычисляет
временные SRP salt и verifier тем же кодом/алгоритмом, что
`Acore::Crypto::SRP6::MakeRegistrationData(canonicalAccount, ticket)`, сохраняет
их вместе с generation и уничтожает открытый ticket после формирования ответа.
Минимальная логическая запись:
```text
account_id UNIQUE
generation_id UNIQUE, unpredictable
srp_salt BINARY(32)
srp_verifier BINARY(32)
launcher_session_hash nullable/optional binding
client_build required binding
issued_at server timestamp
expires_at server timestamp, <= issued_at + 60 seconds
consumed_at nullable
revoked_at nullable
```
Активна только запись, для которой одновременно выполнено:
```text
consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > server_now
```
Если архитектура временно требует хранения открытого ticket, он должен быть
зашифрован отдельным ротируемым ключом, недоступным read-only потребителям БД,
и удалён при погашении/истечении. Хеш ticket сам по себе недостаточен для
построения SRP verifier после выпуска.
## 6. Запуск `Wow.exe`
Launcher **ДОЛЖЕН**:
1. получить ticket непосредственно перед запуском;
2. построить отдельный Unicode environment block для дочернего процесса,
сохранив необходимые штатные переменные и добавив две MoonWell-переменные;
3. вызвать `CreateProcessW` с `CREATE_UNICODE_ENVIRONMENT`;
4. не добавлять credentials в command line;
5. сразу после возврата `CreateProcessW` затереть все собственные изменяемые
буферы с ticket через гарантированно не оптимизируемую операцию, например
`SecureZeroMemory`;
6. уничтожить созданный environment block независимо от результата запуска.
Launcher **НЕ ДОЛЖЕН** менять собственное глобальное окружение через
`SetEnvironmentVariable`, поскольку это создаёт окно утечки и гонки при
параллельных запусках. Переменные должны существовать только в явно собранном
environment block конкретного дочернего процесса.
Ticket нельзя помещать в аргументы, файлы, реестр, crash metadata или логи.
Дамп памяти процесса остаётся привилегированной атакой; MoonWell.dll должна
минимизировать время жизни plaintext и очищать временные буферы.
## 7. Контракт MoonWell.dll и GlueXML
MoonWell.dll до загрузки GlueXML:
1. читает обе launcher-переменные в ограниченные изменяемые буферы;
2. немедленно удаляет их из окружения `Wow.exe`;
3. валидирует обе переменные целиком;
4. передаёт валидную пару GlueXML только в памяти процесса;
5. очищает исходные и промежуточные буферы после начала SRP-входа.
Пара считается валидной только если присутствуют и корректны обе переменные.
Частичная, пустая, слишком длинная или синтаксически неверная пара полностью
отбрасывается. Нельзя переходить к ручному логину из-за ошибки credentials в
Release.
Состояния интерфейса:
| Сборка/вход | `AccountLoginUI` | Основное действие |
| --- | --- | --- |
| Release, валидная пара | скрыт | один вызов `DefaultServerLogin(account, ticket)` |
| Release, пары нет/она невалидна | поля, password и save options скрыты | открыть launcher |
| Debug и `MOONWELL_DEV_LOGIN=1` | прежняя форма видима | штатный ручной SRP-вход |
| Debug без dev-флага | как Release | открыть launcher |
При каждом не-dev запуске клиент удаляет ранее сохранённые account/password и
отключает параметры их сохранения до отображения UI. Повторный автоматический
вызов `DefaultServerLogin` в рамках одного процесса запрещён.
`MOONWELL_DEV_LOGIN` читается и удаляется тем же ранним нативным кодом. Он
учитывается только Debug-сборкой MoonWell.dll; Release-сборка всегда его
игнорирует.
## 8. Открытие launcher
Кнопка «Авторизоваться»:
1. ищет `MoonWellLauncher.exe` в каталоге фактически запущенного `Wow.exe`;
2. если файл найден, запускает его без credentials;
3. иначе открывает `moonwell://authorize?source=client` системным обработчиком.
Путь нельзя брать из current working directory. Перед запуском следует
проверять издателя Authenticode/ожидаемую подпись launcher.
Установщик регистрирует URI scheme только для текущего пользователя в
`HKCU\Software\Classes\moonwell`. Команда обработчика должна корректно заключать
путь executable и аргумент URI в кавычки. Launcher рассматривает URI как
недоверенный ввод, разрешает только известные host/action/query keys и никогда
не принимает credentials через URI.
## 9. Обязательный контракт `authserver`
### 9.1 `CMD_AUTH_LOGON_CHALLENGE`
После обычного разбора и канонизации game account сервер:
1. загружает account, блокировки и режим авторизации;
2. для обычного account ищет активную launcher ticket generation;
3. проверяет expiry, `client_build` и дополнительные привязки;
4. если активной generation нет, возвращает общий отказ до обращения к
постоянным account salt/verifier;
5. сохраняет `generation_id` в объекте конкретной `AuthSession`;
6. строит SRP challenge только из временных salt/verifier этой generation.
Для launcher-only account SRP challenge не должен запрашивать интерактивный
TOTP/security token: MFA является свойством launcher/backend-сессии и должна
быть завершена до выпуска ticket. Иначе скрытый GlueXML не сможет закончить
автоматический вход. Штатный AzerothCore TOTP может оставаться только в явно
описанном dev-потоке с видимой формой.
Для отсутствующего, истёкшего, отозванного или уже использованного ticket
следует возвращать одинаковый протокольный результат, например
`WOW_FAIL_UNKNOWN_ACCOUNT`, чтобы не создавать oracle состояния ticket.
### 9.2 `CMD_AUTH_LOGON_PROOF`
После успешной криптографической проверки proof, TOTP (если применяется) и
версии клиента сервер **ДОЛЖЕН** в одной транзакции:
1. выполнить условное погашение именно generation из текущей `AuthSession`:
```sql
UPDATE launcher_ticket
SET consumed_at = CURRENT_TIMESTAMP(6)
WHERE account_id = ?
AND generation_id = ?
AND consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP(6);
```
2. убедиться, что изменена ровно одна строка;
3. сохранить новую game session key и штатные login metadata;
4. зафиксировать транзакцию;
5. только после commit отправить клиенту успешный `AUTH_LOGON_PROOF`.
Если условное погашение изменило ноль строк или commit не удался, сервер
возвращает общий отказ и не создаёт авторизованную game session. Это обеспечивает
отказ повторному proof даже если два challenge успели получить одну generation.
Неуспешный proof не погашает ticket, но существующие механизмы rate limit и
защиты от перебора продолжают действовать. Ticket всё равно истекает не позднее
60 секунд после выпуска.
### 9.3 Постоянный verifier и dev-доступ
Для обычных accounts `authserver` никогда не выбирает и не проверяет постоянные
`account.salt/account.verifier`. При миграции их следует сделать nullable и
удалить у launcher-only accounts либо заменить отдельным режимом хранения,
который невозможно случайно использовать в launcher-ветке.
Ручной SRP разрешён только если серверная политика независимо подтверждает все
условия:
- account присутствует в явном dev allowlist;
- account имеет режим `DEV_SRP`;
- источник соответствует VPN/IP allowlist;
- production-конфигурация не разрешает dev-режим по умолчанию.
Клиентский Debug-флаг не передаётся authserver и не участвует в решении.
### 9.4 Reconnect
`CMD_AUTH_RECONNECT_*` может использовать session key только от ранее успешно
погашенного ticket или разрешённого dev-входа. При включённом reconnect сервер
должен хранить происхождение/время session key и отклонить legacy session keys,
созданные до внедрения этой политики. Reconnect не оживляет ticket и не позволяет
новому процессу выполнить новый logon без нового ticket.
## 10. Состояния ticket и гонки
Допустимые переходы:
```text
ISSUED -> CONSUMED
ISSUED -> REVOKED (выпущена новая generation)
ISSUED -> EXPIRED (server_now >= expires_at)
```
`CONSUMED`, `REVOKED` и `EXPIRED` — терминальные состояния. Cleanup может
физически удалить терминальные записи позже, но не должен превращать их обратно
в активные.
Выдача новой generation не обязана завершать уже созданную игровую сессию, но
обязана сделать proof старой незавершённой generation невозможным. Это
обеспечивается сравнением `generation_id` при погашении.
## 11. Логи и наблюдаемость
Разрешено логировать только:
- внутренний account id либо необратимо псевдонимизированный идентификатор;
- generation id в хешированном/усечённом виде;
- тип события: issued, rejected, consumed, revoked, expired;
- код результата, build и тайминги без секретов.
Запрещено логировать ticket, SRP verifier, environment block, полный launcher
session token и HTTP response body. Фильтрация секретов должна применяться также
к debug-логам, SQL tracing, crash reports и APM.
## 12. Критерии приёмки
Реализация считается соответствующей контракту, если автоматические или
интеграционные тесты подтверждают:
1. валидный свежий ticket даёт ровно один успешный вход;
2. повторный proof и второй процесс с тем же ticket получают отказ;
3. новый ticket отзывает предыдущий, включая уже полученный старый challenge;
4. ticket старше 60 секунд отклоняется по серверному времени;
5. прямой Release-запуск не показывает и не использует ручные credentials;
6. одна отсутствующая/невалидная environment-переменная не запускает SRP;
7. переменные удалены из окружения до GlueXML и отсутствуют в дочерних
процессах, созданных позже;
8. Release игнорирует `MOONWELL_DEV_LOGIN=1`;
9. Debug dev-login работает только для server-side allowlisted account и
разрешённого источника;
10. обычный account с корректным старым постоянным паролем получает отказ;
11. ticket, URL и command line отсутствуют во всех проверяемых логах;
12. параллельные proof одной generation дают ровно один success;
13. запрещённый client build и несоответствующая launcher-session binding дают
общий отказ без fallback на постоянный verifier;
14. legacy session keys не позволяют обойти launcher-only политику reconnect.
15. launcher-only challenge не требует интерактивного TOTP после запуска
`DefaultServerLogin`.
## 13. Локальный запуск разработчика
Поддерживаемая команда:
```powershell
.\run.ps1 -Env local -DeveloperLogin
```
Скрипт собирает Debug runtime, формирует отдельный environment block и добавляет
`MOONWELL_DEV_LOGIN=1` только запускаемому процессу. Он не изменяет глобальное
окружение пользователя и не делает server-side account dev-разрешённым.
Debug DLL не включается в пользовательские Release-дистрибутивы.