372 lines
22 KiB
Markdown
372 lines
22 KiB
Markdown
# Контракт авторизации 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-дистрибутивы.
|