168 lines
4.9 KiB
Bash
168 lines
4.9 KiB
Bash
#!/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 <<EOF
|
|
Usage: $(basename "$0") <game-account> [--build <client-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
|