From 3405057e5b984a647b30d1d58773699b31bc70e0 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 17 Aug 2026 20:24:17 +0400 Subject: [PATCH] wip --- data/sql/updates/db_auth/2026_08_16_00.sql | 49 +++ doc/LauncherAuthorizationContract.md | 371 ++++++++++++++++++ doc/LauncherAuthorizationOperations.md | 84 ++++ issue-launcher-ticket.sh | 167 ++++++++ .../apps/authserver/Server/AuthSession.cpp | 177 +++++++-- .../apps/authserver/Server/AuthSession.h | 6 + .../Database/Implementation/LoginDatabase.cpp | 24 +- .../Database/Implementation/LoginDatabase.h | 4 + 8 files changed, 839 insertions(+), 43 deletions(-) create mode 100644 data/sql/updates/db_auth/2026_08_16_00.sql create mode 100644 doc/LauncherAuthorizationContract.md create mode 100644 doc/LauncherAuthorizationOperations.md create mode 100644 issue-launcher-ticket.sh diff --git a/data/sql/updates/db_auth/2026_08_16_00.sql b/data/sql/updates/db_auth/2026_08_16_00.sql new file mode 100644 index 000000000..8425167ed --- /dev/null +++ b/data/sql/updates/db_auth/2026_08_16_00.sql @@ -0,0 +1,49 @@ +-- DB update 2026_07_22_00 -> 2026_08_16_00 +-- MoonWell launcher-only authentication. + +ALTER TABLE `account` + ADD COLUMN `session_auth` enum('none','launcher','dev') NOT NULL DEFAULT 'none' AFTER `session_key`; + +UPDATE `account` +SET `session_key` = NULL, + `session_auth` = 'none'; + +CREATE TABLE `launcher_ticket` ( + `account_id` int unsigned NOT NULL, + `generation_id` binary(16) NOT NULL, + `srp_salt` binary(32) NOT NULL, + `srp_verifier` binary(32) NOT NULL, + `client_build` smallint unsigned NOT NULL, + `launcher_session_hash` binary(32) DEFAULT NULL, + `issued_at` datetime(6) NOT NULL, + `expires_at` datetime(6) NOT NULL, + PRIMARY KEY (`account_id`), + UNIQUE KEY `uq_launcher_ticket_generation` (`generation_id`), + KEY `idx_launcher_ticket_expiry` (`expires_at`), + CONSTRAINT `fk_launcher_ticket_account` + FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE, + CONSTRAINT `chk_launcher_ticket_expiry` + CHECK (`expires_at` > `issued_at` AND `expires_at` <= `issued_at` + INTERVAL 60 SECOND) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `launcher_ticket_consumption` ( + `generation_id` binary(16) NOT NULL, + `account_id` int unsigned NOT NULL, + `claim_nonce` binary(16) NOT NULL, + `consumed_at` datetime(6) NOT NULL, + PRIMARY KEY (`generation_id`), + UNIQUE KEY `uq_launcher_ticket_claim_nonce` (`claim_nonce`), + KEY `idx_launcher_ticket_consumption_account` (`account_id`, `consumed_at`), + CONSTRAINT `fk_launcher_ticket_consumption_account` + FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `launcher_dev_account` ( + `account_id` int unsigned NOT NULL, + `allowed_ip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `comment` varchar(255) NOT NULL DEFAULT '', + `enabled` tinyint unsigned NOT NULL DEFAULT 1, + PRIMARY KEY (`account_id`, `allowed_ip`), + CONSTRAINT `fk_launcher_dev_account_account` + FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/doc/LauncherAuthorizationContract.md b/doc/LauncherAuthorizationContract.md new file mode 100644 index 000000000..e69eaa086 --- /dev/null +++ b/doc/LauncherAuthorizationContract.md @@ -0,0 +1,371 @@ +# Контракт авторизации 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= +MOONWELL_LAUNCH_TICKET= +``` + +### 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-дистрибутивы. diff --git a/doc/LauncherAuthorizationOperations.md b/doc/LauncherAuthorizationOperations.md new file mode 100644 index 000000000..925b5d102 --- /dev/null +++ b/doc/LauncherAuthorizationOperations.md @@ -0,0 +1,84 @@ +# Эксплуатация launcher-авторизации + +Нормативный протокол описан в +[`LauncherAuthorizationContract.md`](LauncherAuthorizationContract.md). Этот +документ относится к реализованной в данном репозитории серверной части. + +## Что входит в репозиторий + +- `authserver` использует временные SRP salt/verifier для всех обычных + аккаунтов и не делает fallback на постоянный пароль; +- SQL update `2026_08_16_00.sql` добавляет активные ticket, журнал погашений, + точный IP allowlist для dev-аккаунтов и provenance session key; +- `issue-launcher-ticket.sh` является reference adapter для backend и локальной + интеграции. Production backend должен реализовать ту же операцию внутри + своего доверенного процесса, включая аутентификацию launcher session и rate + limiting; +- reconnect и вход в worldserver разрешены только session key с provenance + `launcher` или `dev`. + +Исходников MoonWell Launcher, MoonWell.dll, GlueXML и backend в этом +репозитории нет. Их реализация должна следовать отдельным разделам контракта. + +## Выпуск ticket + +База должна быть запущена и обновлена штатным `ac-db-import`: + +```bash +./issue-launcher-ticket.sh PLAYER --build 12340 --ttl 60 +``` + +Успешный результат — одна JSON-строка в stdout. Она содержит plaintext ticket +и должна быть сразу передана launcher по TLS с `Cache-Control: no-store`. +Диагностика выводится в stderr. Новый вызов для того же account атомарно +заменяет предыдущую активную generation. + +Скрипт не является публичным HTTP endpoint. Нельзя предоставлять игрокам +доступ к его запуску или к MySQL credentials. + +## Dev allowlist + +Постоянный verifier используется только при совпадении account и точного +remote IP с активной строкой allowlist: + +```sql +INSERT INTO launcher_dev_account (account_id, allowed_ip, comment) +SELECT id, '10.20.30.40', 'local developer over VPN' +FROM account +WHERE username = 'DEVACCOUNT' +ON DUPLICATE KEY UPDATE enabled = 1, comment = VALUES(comment); +``` + +Отключение: + +```sql +UPDATE launcher_dev_account +SET enabled = 0 +WHERE account_id = (SELECT id FROM account WHERE username = 'DEVACCOUNT') + AND allowed_ip = '10.20.30.40'; +``` + +CIDR и wildcard намеренно не поддерживаются: allowlist требует точного IP. + +## Миграция и откат + +SQL update очищает существующие `account.session_key`, чтобы legacy reconnect +не обходил launcher-only политику. После применения старые клиенты должны +получить новый launcher ticket. + +Миграция безопасности намеренно не имеет автоматического fallback. Перед +развёртыванием должны быть готовы backend issuer и Release-клиент. Для +аварийного dev-доступа заранее добавьте только выделенный account и VPN IP в +`launcher_dev_account`. + +## Проверка + +После сборки и применения update: + +1. выпустить ticket для тестового account; +2. убедиться, что прямой вход со старым паролем отвергнут; +3. войти с ticket; +4. убедиться, что строка удалена из `launcher_ticket` и появилась в + `launcher_ticket_consumption`; +5. повторить вход с тем же ticket и получить отказ; +6. выпустить два ticket подряд и убедиться, что работает только второй. diff --git a/issue-launcher-ticket.sh b/issue-launcher-ticket.sh new file mode 100644 index 000000000..a1d1b08ed --- /dev/null +++ b/issue-launcher-ticket.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}" +CLIENT_BUILD="" +TTL_SECONDS=60 + +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +CLIENT_BUILD="${ACORE_REALMLIST_GAMEBUILD:-12340}" + +usage() { + cat < [--build ] [--ttl <1-60>] + +Issues one single-use MoonWell launcher ticket and revokes the account's +previous active ticket. The JSON response containing the ticket is written +only to stdout; diagnostics go to stderr. +EOF +} + +if (($# == 0)) || [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +ACCOUNT="$1" +shift + +while (($#)); do + case "$1" in + --build) + [[ $# -ge 2 ]] || { printf '%s\n' '--build requires a value' >&2; exit 1; } + CLIENT_BUILD="$2" + shift 2 + ;; + --ttl) + [[ $# -ge 2 ]] || { printf '%s\n' '--ttl requires a value' >&2; exit 1; } + TTL_SECONDS="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 1 + ;; + esac +done + +if ! [[ "$CLIENT_BUILD" =~ ^[0-9]+$ ]] || ((CLIENT_BUILD < 1 || CLIENT_BUILD > 65535)); then + printf '%s\n' 'client build must be an integer from 1 to 65535' >&2 + exit 1 +fi + +if ! [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] || ((TTL_SECONDS < 1 || TTL_SECONDS > 60)); then + printf '%s\n' 'ttl must be an integer from 1 to 60 seconds' >&2 + exit 1 +fi + +export MOONWELL_ISSUER_ACCOUNT="$ACCOUNT" +mapfile -t ACCOUNT_DATA < <(python3 <<'PY' +import json +import os + +account = os.environ["MOONWELL_ISSUER_ACCOUNT"] +account = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in account) +encoded = account.encode("ascii", "strict") + +if not encoded or len(encoded) > 17: + raise SystemExit("game account must contain 1 to 17 ASCII bytes") +if any(byte < 0x21 or byte > 0x7E for byte in encoded) or b"=" in encoded: + raise SystemExit("game account must be printable ASCII without whitespace or '='") + +print(account) +print(encoded.hex().upper()) +print(json.dumps(account, ensure_ascii=True)) +PY +) +unset MOONWELL_ISSUER_ACCOUNT ACCOUNT + +CANONICAL_ACCOUNT="${ACCOUNT_DATA[0]}" +ACCOUNT_HEX="${ACCOUNT_DATA[1]}" +ACCOUNT_JSON="${ACCOUNT_DATA[2]}" +unset ACCOUNT_DATA + +cd "$ROOT_DIR" + +if ! docker compose ps --status running --services | grep -qx 'ac-database'; then + printf '%s\n' 'ac-database is not running; start it with ./start-server.sh' >&2 + exit 1 +fi + +ACCOUNT_ID="$(docker compose exec -T ac-database bash -lc \ + 'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' \ + <<<"SELECT id FROM account WHERE username = CONVERT(0x$ACCOUNT_HEX USING ascii) LIMIT 1;")" + +if ! [[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]]; then + printf 'game account does not exist: %s\n' "$CANONICAL_ACCOUNT" >&2 + exit 1 +fi + +export MOONWELL_ISSUER_CANONICAL_ACCOUNT="$CANONICAL_ACCOUNT" +mapfile -t TICKET_DATA < <(python3 <<'PY' +import hashlib +import os +import secrets + +alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +account = os.environ["MOONWELL_ISSUER_CANONICAL_ACCOUNT"] +ticket = "".join(secrets.choice(alphabet) for _ in range(16)) +generation = secrets.token_bytes(16) +salt = secrets.token_bytes(32) + +n = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16) +inner = hashlib.sha1(f"{account}:{ticket}".encode("ascii")).digest() +x = hashlib.sha1(salt + inner).digest() +verifier = pow(7, int.from_bytes(x, "little"), n).to_bytes(32, "little") + +print(generation.hex().upper()) +print(salt.hex().upper()) +print(verifier.hex().upper()) +print(ticket) +PY +) +unset MOONWELL_ISSUER_CANONICAL_ACCOUNT + +GENERATION_HEX="${TICKET_DATA[0]}" +SALT_HEX="${TICKET_DATA[1]}" +VERIFIER_HEX="${TICKET_DATA[2]}" +TICKET="${TICKET_DATA[3]}" +unset TICKET_DATA + +SQL="INSERT INTO launcher_ticket + (account_id, generation_id, srp_salt, srp_verifier, client_build, issued_at, expires_at) +VALUES + ($ACCOUNT_ID, UNHEX('$GENERATION_HEX'), UNHEX('$SALT_HEX'), UNHEX('$VERIFIER_HEX'), + $CLIENT_BUILD, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6) + INTERVAL $TTL_SECONDS SECOND) +ON DUPLICATE KEY UPDATE + generation_id = VALUES(generation_id), + srp_salt = VALUES(srp_salt), + srp_verifier = VALUES(srp_verifier), + client_build = VALUES(client_build), + launcher_session_hash = NULL, + issued_at = VALUES(issued_at), + expires_at = VALUES(expires_at); +SELECT DATE_FORMAT(expires_at, '%Y-%m-%dT%H:%i:%sZ') +FROM launcher_ticket +WHERE account_id = $ACCOUNT_ID;" + +EXPIRES_AT="$(docker compose exec -T ac-database bash -lc \ + 'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' <<<"$SQL")" + +printf '{"account":%s,"ticket":"%s","expires_at":"%s","client_build":%s}\n' \ + "$ACCOUNT_JSON" "$TICKET" "$EXPIRES_AT" "$CLIENT_BUILD" + +TICKET="$(printf '%*s' 16 '')" +unset TICKET SQL GENERATION_HEX SALT_HEX VERIFIER_HEX ACCOUNT_HEX CANONICAL_ACCOUNT ACCOUNT_JSON diff --git a/src/server/apps/authserver/Server/AuthSession.cpp b/src/server/apps/authserver/Server/AuthSession.cpp index b0692a03b..f533408ad 100644 --- a/src/server/apps/authserver/Server/AuthSession.cpp +++ b/src/server/apps/authserver/Server/AuthSession.cpp @@ -183,6 +183,7 @@ bool AuthSession::Update() return false; _queryProcessor.ProcessReadyCallbacks(); + _transactionProcessor.ProcessReadyCallbacks(); return true; } @@ -309,7 +310,9 @@ bool AuthSession::HandleLogonChallenge() // Get the account details from the account table LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LOGONCHALLENGE); stmt->SetData(0, GetRemoteIpAddress().to_string()); - stmt->SetData(1, login); + stmt->SetData(1, GetRemoteIpAddress().to_string()); + stmt->SetData(2, _build); + stmt->SetData(3, login); _queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt).WithPreparedCallback(std::bind(&AuthSession::LogonChallengeCallback, this, std::placeholders::_1))); return true; @@ -387,8 +390,21 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result) uint8 securityFlags = 0; - // Check if a TOTP token is needed - if (!fields[12].IsNull()) + _isDeveloperLogin = fields[15].Get(); + _launcherTicketGeneration.reset(); + + if (!_isDeveloperLogin && (fields[16].IsNull() || fields[17].IsNull() || fields[18].IsNull())) + { + pkt << uint8(WOW_FAIL_UNKNOWN_ACCOUNT); + SendPacket(pkt); + LOG_INFO("server.authserver", "'{}:{}' [AuthChallenge] launcher ticket required for account id {}", + ipAddress, port, _accountInfo.Id); + return; + } + + // Launcher authentication completes MFA before ticket issuance. Only the + // explicitly allowlisted developer flow may request the legacy TOTP UI. + if (_isDeveloperLogin && !fields[12].IsNull()) { securityFlags = 4; _totpSecret = fields[12].Get(); @@ -406,9 +422,19 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result) } } - _srp6.emplace(_accountInfo.Login, - fields[13].Get(), - fields[14].Get()); + if (_isDeveloperLogin) + { + _srp6.emplace(_accountInfo.Login, + fields[13].Get(), + fields[14].Get()); + } + else + { + _launcherTicketGeneration = fields[16].Get(); + _srp6.emplace(_accountInfo.Login, + fields[17].Get(), + fields[18].Get()); + } // Fill the response packet with the result if (AuthHelper::IsAcceptedClientBuild(_build)) @@ -515,45 +541,77 @@ bool AuthSession::HandleLogonProof() // No SQL injection (escaped user name) and IP address as received by socket std::string address = sConfigMgr->GetOption("AllowLoggingIPAddressesInDatabase", true, true) ? GetRemoteIpAddress().to_string() : "0.0.0.0"; - LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF); - stmt->SetData(0, _sessionKey); - stmt->SetData(1, address); - stmt->SetData(2, GetLocaleByName(_localizationName)); - stmt->SetData(3, _os); - stmt->SetData(4, _accountInfo.Login); - _queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt) - .WithPreparedCallback([this, M2 = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey)](PreparedQueryResult const&) + Acore::Crypto::SHA1::Digest serverProof = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey); + + if (_isDeveloperLogin) { - // Finish SRP6 and send the final result to the client - ByteBuffer packet; - if (_expversion & POST_BC_EXP_FLAG) // 2.x and 3.x clients + LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF); + stmt->SetData(0, _sessionKey); + stmt->SetData(1, address); + stmt->SetData(2, GetLocaleByName(_localizationName)); + stmt->SetData(3, _os); + stmt->SetData(4, _accountInfo.Login); + _queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt) + .WithPreparedCallback([this, serverProof](PreparedQueryResult const&) { - sAuthLogonProof_S proof; - proof.M2 = M2; - proof.cmd = AUTH_LOGON_PROOF; - proof.error = 0; - proof.AccountFlags = _accountInfo.Flags; - proof.SurveyId = 0; - proof.LoginFlags = 0; // 0x1 = has account message + SendLogonProofSuccess(serverProof); + })); + } + else + { + ASSERT(_launcherTicketGeneration); + std::array claimNonce = Acore::Crypto::GetRandomBytes<16>(); - packet.resize(sizeof(proof)); - std::memcpy(packet.contents(), &proof, sizeof(proof)); - } - else + LoginDatabaseTransaction transaction = LoginDatabase.BeginTransaction(); + + LoginDatabasePreparedStatement* consume = LoginDatabase.GetPreparedStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION); + consume->SetData(0, claimNonce); + consume->SetData(1, _accountInfo.Id); + consume->SetData(2, *_launcherTicketGeneration); + transaction->Append(consume); + + LoginDatabasePreparedStatement* remove = LoginDatabase.GetPreparedStatement(LOGIN_DEL_LAUNCHER_TICKET); + remove->SetData(0, _accountInfo.Id); + remove->SetData(1, *_launcherTicketGeneration); + transaction->Append(remove); + + LoginDatabasePreparedStatement* update = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF); + update->SetData(0, _sessionKey); + update->SetData(1, address); + update->SetData(2, GetLocaleByName(_localizationName)); + update->SetData(3, _os); + update->SetData(4, _accountInfo.Id); + update->SetData(5, *_launcherTicketGeneration); + update->SetData(6, claimNonce); + transaction->Append(update); + + std::array generation = *_launcherTicketGeneration; + _transactionProcessor.AddCallback(LoginDatabase.AsyncCommitTransaction(transaction)).AfterComplete( + [this, serverProof, generation, claimNonce](bool success) { - sAuthLogonProof_S_Old proof; - proof.M2 = M2; - proof.cmd = AUTH_LOGON_PROOF; - proof.error = 0; - proof.unk2 = 0x00; + if (!success) + { + SendLogonProofFailure(); + return; + } - packet.resize(sizeof(proof)); - std::memcpy(packet.contents(), &proof, sizeof(proof)); - } + LoginDatabasePreparedStatement* claim = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM); + claim->SetData(0, _accountInfo.Id); + claim->SetData(1, generation); + claim->SetData(2, claimNonce); + _queryProcessor.AddCallback(LoginDatabase.AsyncQuery(claim) + .WithPreparedCallback([this, serverProof](PreparedQueryResult result) + { + if (!result) + { + SendLogonProofFailure(); + return; + } - SendPacket(packet); - _status = STATUS_AUTHED; - })); + SendLogonProofSuccess(serverProof); + })); + }); + } } else { @@ -618,6 +676,47 @@ bool AuthSession::HandleLogonProof() return true; } +void AuthSession::SendLogonProofFailure() +{ + ByteBuffer packet; + packet << uint8(AUTH_LOGON_PROOF); + packet << uint8(WOW_FAIL_UNKNOWN_ACCOUNT); + packet << uint16(0); + SendPacket(packet); +} + +void AuthSession::SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof) +{ + ByteBuffer packet; + if (_expversion & POST_BC_EXP_FLAG) + { + sAuthLogonProof_S proof; + proof.M2 = serverProof; + proof.cmd = AUTH_LOGON_PROOF; + proof.error = 0; + proof.AccountFlags = _accountInfo.Flags; + proof.SurveyId = 0; + proof.LoginFlags = 0; + + packet.resize(sizeof(proof)); + std::memcpy(packet.contents(), &proof, sizeof(proof)); + } + else + { + sAuthLogonProof_S_Old proof; + proof.M2 = serverProof; + proof.cmd = AUTH_LOGON_PROOF; + proof.error = 0; + proof.unk2 = 0x00; + + packet.resize(sizeof(proof)); + std::memcpy(packet.contents(), &proof, sizeof(proof)); + } + + SendPacket(packet); + _status = STATUS_AUTHED; +} + bool AuthSession::HandleReconnectChallenge() { _status = STATUS_CLOSED; diff --git a/src/server/apps/authserver/Server/AuthSession.h b/src/server/apps/authserver/Server/AuthSession.h index 0ea2fafff..1cc6934a7 100644 --- a/src/server/apps/authserver/Server/AuthSession.h +++ b/src/server/apps/authserver/Server/AuthSession.h @@ -27,6 +27,7 @@ #include "QueryResult.h" #include "SRP6.h" #include "Socket.h" +#include "Transaction.h" #include using boost::asio::ip::tcp; @@ -89,10 +90,14 @@ private: void LogonChallengeCallback(PreparedQueryResult result); void ReconnectChallengeCallback(PreparedQueryResult result); void RealmListCallback(PreparedQueryResult result); + void SendLogonProofFailure(); + void SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof); bool VerifyVersion(uint8 const* a, int32 aLength, Acore::Crypto::SHA1::Digest const& versionProof, bool isReconnect); Optional _srp6; + Optional> _launcherTicketGeneration; + bool _isDeveloperLogin = false; SessionKey _sessionKey = {}; std::array _reconnectProof = {}; @@ -106,6 +111,7 @@ private: uint8 _expversion; QueryCallbackProcessor _queryProcessor; + AsyncCallbackProcessor _transactionProcessor; }; #pragma pack(push, 1) diff --git a/src/server/database/Database/Implementation/LoginDatabase.cpp b/src/server/database/Database/Implementation/LoginDatabase.cpp index 91c7e6963..3dedef977 100644 --- a/src/server/database/Database/Implementation/LoginDatabase.cpp +++ b/src/server/database/Database/Implementation/LoginDatabase.cpp @@ -27,11 +27,14 @@ void LoginDatabaseConnection::DoPrepareStatements() "SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, " "ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, ab.unbandate = ab.bandate, " "ipb.unbandate > UNIX_TIMESTAMP() OR ipb.unbandate = ipb.bandate, ipb.unbandate = ipb.bandate, " - "aa.gmlevel, a.totp_secret, a.salt, a.verifier " + "aa.gmlevel, a.totp_secret, a.salt, a.verifier, " + "lda.account_id IS NOT NULL, lt.generation_id, lt.srp_salt, lt.srp_verifier " "FROM account a " "LEFT JOIN account_access aa ON a.id = aa.id " "LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 " "LEFT JOIN ip_banned ipb ON ipb.ip = ? " + "LEFT JOIN launcher_dev_account lda ON lda.account_id = a.id AND lda.allowed_ip = ? AND lda.enabled = 1 " + "LEFT JOIN launcher_ticket lt ON lt.account_id = a.id AND lt.expires_at > UTC_TIMESTAMP(6) AND lt.client_build = ? " "WHERE a.username = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_RECONNECTCHALLENGE, "SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, " @@ -42,11 +45,11 @@ void LoginDatabaseConnection::DoPrepareStatements() "LEFT JOIN account_access aa ON a.id = aa.id " "LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 " "LEFT JOIN ip_banned ipb ON ipb.ip = ? " - "WHERE a.username = ? AND a.session_key IS NOT NULL", CONNECTION_ASYNC); + "WHERE a.username = ? AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev')", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_ACCOUNT_INFO_BY_NAME, "SELECT a.id, a.session_key, a.last_ip, a.locked, a.lock_country, a.expansion, a.Flags, a.mutetime, a.locale, a.recruiter, a.os, a.totaltime, " "aa.gmlevel, ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, r.id FROM account a LEFT JOIN account_access aa ON a.id = aa.id AND aa.RealmID IN (-1, ?) " "LEFT JOIN account_banned ab ON a.id = ab.id AND ab.active = 1 LEFT JOIN account r ON a.id = r.recruiter WHERE a.username = ? " - "AND a.session_key IS NOT NULL ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC); + "AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev') ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_IP_INFO, "SELECT unbandate > UNIX_TIMESTAMP() OR unbandate = bandate AS banned, NULL as country FROM ip_banned WHERE ip = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_REALMLIST, "SELECT id, name, address, localAddress, localSubnetMask, port, icon, flag, timezone, allowedSecurityLevel, population, gamebuild FROM realmlist WHERE flag <> 3 ORDER BY name", CONNECTION_SYNCH); PrepareStatement(LOGIN_DEL_EXPIRED_IP_BANS, "DELETE FROM ip_banned WHERE unbandate<>bandate AND unbandate<=UNIX_TIMESTAMP()", CONNECTION_ASYNC); @@ -61,7 +64,20 @@ void LoginDatabaseConnection::DoPrepareStatements() PrepareStatement(LOGIN_INS_ACCOUNT_AUTO_BANNED, "INSERT INTO account_banned VALUES (?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()+?, 'realmd', 'Failed login autoban', 1)", CONNECTION_ASYNC); PrepareStatement(LOGIN_DEL_ACCOUNT_BANNED, "DELETE FROM account_banned WHERE id = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_UPD_LOGON, "UPDATE account SET salt = ?, verifier = ? WHERE id = ?", CONNECTION_ASYNC); - PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC); + PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, session_auth = 'dev', last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC); + PrepareStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION, + "INSERT INTO launcher_ticket_consumption (generation_id, account_id, claim_nonce, consumed_at) " + "SELECT generation_id, account_id, ?, UTC_TIMESTAMP(6) FROM launcher_ticket " + "WHERE account_id = ? AND generation_id = ? AND expires_at > UTC_TIMESTAMP(6)", CONNECTION_ASYNC); + PrepareStatement(LOGIN_DEL_LAUNCHER_TICKET, + "DELETE FROM launcher_ticket WHERE account_id = ? AND generation_id = ?", CONNECTION_ASYNC); + PrepareStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF, + "UPDATE account a JOIN launcher_ticket_consumption c ON c.account_id = a.id " + "SET a.session_key = ?, a.session_auth = 'launcher', a.last_ip = ?, a.last_login = NOW(), " + "a.locale = ?, a.failed_logins = 0, a.os = ? " + "WHERE a.id = ? AND c.generation_id = ? AND c.claim_nonce = ?", CONNECTION_ASYNC); + PrepareStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM, + "SELECT 1 FROM launcher_ticket_consumption WHERE account_id = ? AND generation_id = ? AND claim_nonce = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_UPD_FAILEDLOGINS, "UPDATE account SET failed_logins = failed_logins + 1 WHERE username = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_FAILEDLOGINS, "SELECT id, failed_logins FROM account WHERE username = ?", CONNECTION_SYNCH); PrepareStatement(LOGIN_SEL_ACCOUNT_ID_BY_NAME, "SELECT id FROM account WHERE username = ?", CONNECTION_SYNCH); diff --git a/src/server/database/Database/Implementation/LoginDatabase.h b/src/server/database/Database/Implementation/LoginDatabase.h index 420a6150a..4eddb80ef 100644 --- a/src/server/database/Database/Implementation/LoginDatabase.h +++ b/src/server/database/Database/Implementation/LoginDatabase.h @@ -41,6 +41,10 @@ enum LoginDatabaseStatements : uint32 LOGIN_DEL_ACCOUNT_BANNED, LOGIN_UPD_LOGON, LOGIN_UPD_LOGONPROOF, + LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION, + LOGIN_DEL_LAUNCHER_TICKET, + LOGIN_UPD_LAUNCHER_LOGONPROOF, + LOGIN_SEL_LAUNCHER_TICKET_CLAIM, LOGIN_SEL_LOGONCHALLENGE, LOGIN_SEL_RECONNECTCHALLENGE, LOGIN_UPD_FAILEDLOGINS,