Files
sindoring 8c8e982fc3 Enable launcher-free local development builds
Keep launcher tickets mandatory by default and compile password authentication only into the dedicated local image. Build the shared local image once and add an end-to-end SRP login check.
2026-10-18 12:11:35 +04:00

137 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Exercise local SRP login without launcher tickets using a disposable account."""
import argparse
import hashlib
from pathlib import Path
import secrets
import socket
import struct
import subprocess
COMPOSE = Path(__file__).resolve().with_name("compose.sh")
N = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16)
def sha(data):
return hashlib.sha1(data).digest()
def number_bytes(value, size=None):
return value.to_bytes(size if size is not None else (value.bit_length() + 7) // 8, "little")
def sql(statement):
result = subprocess.run(
[str(COMPOSE), "exec", "-T", "db", "sh", "-c",
'MYSQL_PWD="$(cat /run/secrets/db_password)" exec mariadb -uvmangos --batch --skip-column-names realmd'],
input=statement, text=True, capture_output=True, check=True,
)
return result.stdout.strip()
def receive(connection, size):
result = b""
while len(result) < size:
chunk = connection.recv(size - len(result))
if not chunk:
raise AssertionError("Authentication connection closed unexpectedly")
result += chunk
return result
def challenge(connection, username):
name = username.encode("ascii")
body = struct.pack("<4sBBBH4s4s4sIIB", b"WoW\0", 1, 12, 1, 5875,
b"68x\0", b"niW\0", b"URur", 0, 0x0100007F, len(name)) + name
connection.sendall(struct.pack("<BBH", 0, 0, len(body)) + body)
prefix = receive(connection, 3)
assert prefix[:2] == b"\0\0", prefix
if prefix[2]:
return prefix[2], None
public = int.from_bytes(receive(connection, 32), "little")
generator = int.from_bytes(receive(connection, receive(connection, 1)[0]), "little")
modulus = int.from_bytes(receive(connection, receive(connection, 1)[0]), "little")
salt = int.from_bytes(receive(connection, 32), "little")
receive(connection, 16) # Version challenge seed.
assert receive(connection, 1) == b"\0", "Disposable account unexpectedly requires a PIN"
assert modulus == N and generator == 7
return 0, (public, generator, salt)
def login(port, username, password, integrity, expect_success):
with socket.create_connection(("127.0.0.1", port), timeout=10) as connection:
code, parameters = challenge(connection, username)
assert code == 0, f"Logon challenge rejected with code {code}"
public, generator, salt = parameters
private = secrets.randbits(152)
client_public = pow(generator, private, N)
public_bytes = number_bytes(client_public)
server_bytes = number_bytes(public)
salt_bytes = number_bytes(salt)
scramble = int.from_bytes(sha(public_bytes + server_bytes), "little")
exponent = int.from_bytes(sha(salt_bytes + sha(f"{username}:{password}".encode())), "little")
shared = pow((public - 3 * pow(generator, exponent, N)) % N, private + scramble * exponent, N)
shared_bytes = number_bytes(shared, 32)
even, odd = sha(shared_bytes[::2]), sha(shared_bytes[1::2])
key = bytes(value for pair in zip(even, odd) for value in pair)
key_bytes = number_bytes(int.from_bytes(key, "little"))
xor_hash = bytes(a ^ b for a, b in zip(sha(number_bytes(N)), sha(number_bytes(generator))))
proof = sha(number_bytes(int.from_bytes(xor_hash, "little")) + sha(username.encode())
+ salt_bytes + public_bytes + server_bytes + key_bytes)
wire_public = number_bytes(client_public, 32)
version_proof = sha(wire_public + integrity)
connection.sendall(b"\x01" + wire_public + proof + version_proof + b"\0\0")
result = receive(connection, 2)
assert result[0] == 1
if not expect_success:
assert result[1] == 4, f"Wrong password was not rejected: {result.hex()}"
return
assert result[1] == 0, f"Logon proof rejected with code {result[1]}"
expected_proof = sha(public_bytes + number_bytes(int.from_bytes(proof, "little")) + key_bytes)
assert receive(connection, 20) == expected_proof, "Server SRP proof mismatch"
receive(connection, 4) # Vanilla survey id.
stored = sql(f"SELECT sessionkey FROM account WHERE username='{username}';")
assert int(stored, 16) == int.from_bytes(key, "little"), "Session key was not published"
connection.sendall(b"\x10\0\0\0\0")
assert receive(connection, 1) == b"\x10"
size = int.from_bytes(receive(connection, 2), "little")
realms = receive(connection, size)
assert b"127.0.0.1:8085\0" in realms, "Local realm missing from authenticated realm list"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=3724)
parser.add_argument("--required-port", type=int, help="Optional local ticket-required realmd for the negative test")
args = parser.parse_args()
username = "LCLSMOKE" + secrets.token_hex(3).upper()
password = secrets.token_hex(8).upper()
salt = secrets.randbits(255) | (1 << 255)
exponent = int.from_bytes(sha(number_bytes(salt) + sha(f"{username}:{password}".encode())), "little")
verifier = pow(7, exponent, N)
sql(f"INSERT INTO account (username,v,s,token_key,email_verif) "
f"VALUES ('{username}','{verifier:X}','{salt:X}','',1);")
try:
assert sql(f"SELECT COUNT(*) FROM launcher_ticket t JOIN account a ON a.id=t.account_id "
f"WHERE a.username='{username}';") == "0"
integrity = bytes.fromhex(sql("SELECT integrity_hash FROM allowed_clients "
"WHERE build=5875 AND platform='x86' AND os='Win' LIMIT 1;"))
if args.required_port:
with socket.create_connection(("127.0.0.1", args.required_port), timeout=10) as connection:
code, _ = challenge(connection, username)
assert code == 4, f"Ticket-required build accepted a ticketless account: {code}"
print("PASS: ticket-required build rejects an account without a launcher ticket")
login(args.port, username, password + "WRONG", integrity, False)
print("PASS: local build rejects an incorrect password")
login(args.port, username, password, integrity, True)
print("PASS: ticketless SRP login, server proof, session key and local realm list")
finally:
sql(f"DELETE FROM account WHERE username='{username}';")
print("Disposable account removed")
if __name__ == "__main__":
main()