Files
azul/front/msim/ctrl.py
T
Athena Funderburg 21f38ee3e1 production init
2026-05-26 16:41:23 +00:00

615 lines
16 KiB
Python

import asyncio
import base64
import io
import random
import secrets
import hashlib
from core import event
from core.backend import Backend, BackendSession, Chat
from core.client import Client
from core.models import LoginOption, Contact, Substatus, User, TextWithData, OIM, Circle, CircleRole, List
from settings import TARGET_HOST
from util.misc import Logger
from typing import Optional, Callable, Dict, Iterable, Any
from arc4 import ARC4
from .misc import get_grouped_contacts, unmarshal_msim_dict, CommandBitFlag, get_contact_groups
last_sesskey = 0
class MSIMCtrl:
__slots__ = (
'logger',
'reader',
'writer',
'close_callback',
'closed',
'transport',
'backend',
'bs',
'client',
'nonce',
'sesskey',
'keep_alive_task'
)
# Escargot-specific
logger: Logger
reader: 'MSIMReader'
writer: 'MSIMWriter'
close_callback: Optional[Callable[[], None]]
closed: bool
transport: Optional[asyncio.WriteTransport]
backend: Backend
bs: Optional[BackendSession]
client: Client
# Frontend-specific
nonce: bytes
sesskey: int
keep_alive_task: Optional[asyncio.Task]
def __init__(self, logger: Logger, via: str, backend: Backend) -> None:
self.logger = logger
self.reader = MSIMReader(logger)
self.writer = MSIMWriter(logger)
self.close_callback = None
self.closed = False
self.transport = None
self.backend = backend
self.bs = None
self.client = Client('msim', '?', via)
global last_sesskey
last_sesskey += 1
self.nonce = secrets.token_bytes(64)
self.sesskey = last_sesskey
self.keep_alive_task = None
def _m_login2(self, data_pairs: Dict[str, str]) -> None:
email = data_pairs['username']
response = base64.b64decode(data_pairs['response'])
clientver = data_pairs['clientver']
valid = False
# get nc1 & nc2 (first/last 0x20 bytes respectively)
nc1, nc2 = self.nonce[:32], self.nonce[32:]
# log what we got
self.logger.info('Packet email:', email)
self.logger.info('Packet response:', response)
self.logger.info('Packet clientver:', clientver)
if (uuid := self.backend.util_get_uuid_from_email(email)) is None:
self.logger.info('E-mail not found!')
else:
# get our hashed password
hashed_pwd = self.backend.user_service.msim_get_sha1_password(email)
if hashed_pwd is None:
self.logger.info('MySpaceIM frontend-specific password not found!')
else:
final_hash = hashlib.sha1(b''.join([hashed_pwd, nc2])).digest()
rc4_key = final_hash[:16] # we only need the first 128 bits of the result
self.logger.info('Hashed password:', hashed_pwd.hex(' '))
self.logger.info('Full hash:', final_hash.hex(' '))
self.logger.info('RC4 key:', rc4_key.hex(' '))
arc4 = ARC4(rc4_key)
blob = arc4.decrypt(response)
self.logger.info('RC4 blob data:', blob)
seperator = blob.find(bytes(4))
if seperator != -1:
blob_nc1 = blob[:32]
blob_email = blob[32:seperator]
self.logger.info('nc1 (context):', nc1)
self.logger.info('nc1 (request):', blob_nc1)
self.logger.info('E-mail (blob):', blob_email)
if nc1 == blob_nc1 and email == blob_email.decode():
valid = True
# allow user in if valid flag was set
if valid:
# set client to use `clientver`
self.client = Client('msim', f'1.0.{clientver}.0', self.client.via)
# set BackendSession
self.bs = self.backend.login(uuid, self.client, BackendEventHandler(self), option=LoginOption.BootOthers)
# log that they successfully signed in and send reply giving sesskey / user id / username / etc
self.logger.info(email, "successfully signed-in!")
self.send_reply({
'lc': 2,
'sesskey': self.sesskey,
'proof': random.randint(0x00000, 0xFFFFF),
'userid': self.bs.user.id,
'profileid': self.bs.user.id,
'uniquenick': self.bs.user.username,
'id': 1
})
# start 180-second timer to send keep-alives periodically
self.keep_alive_task = asyncio.create_task(self.send_keep_alive_periodically())
else:
self.send_reply({
'error': True,
'errmsg': 'The password provided is incorrect.',
'err': 260,
'fatal': True
})
self.close()
def _m_persist(self, data_pairs: Dict[str, str]) -> None:
bs = self.bs
user = bs.user
detail = user.detail
# get cmd, dsn, lid (command type/family/subcode respectively)
cmd = int(data_pairs['cmd']) # i.e. 1 - Get, 2 - Action, 3 - Delete
dsn = int(data_pairs['dsn'])
lid = int(data_pairs['lid'])
# get request/response ID
rid = int(data_pairs['rid'])
# get body
body = data_pairs['body']
def get_user_by_id(id: int) -> Optional[User]:
if (uuid := self.backend.util_get_uuid_from_user_id(id)) is None:
self.logger.info('User ID', id, 'not found!')
self.send_persist_error(cmd, dsn, lid, rid, f'User ID {id} not found')
return None
found_user = self.backend._load_user_record(uuid)
if found_user is None:
self.logger.info('Unable to find uuid')
self.send_persist_error(cmd, dsn, lid, rid, 'Unable to find uuid')
return None
return found_user
match (cmd, dsn, lid):
# 1;0;1 - list all contacts
case (1, 0, 1):
msim_contacts = []
grouped_contacts = get_grouped_contacts(self.bs)
pos = 0
for group, contacts in grouped_contacts.items():
for contact in contacts:
head = contact.head
pos += 1
# For some reason, the MySpaceIM devs made it UNIX time in nano-seconds.
# *Ahem* Kill me.
last_login = int(head.date_login.timestamp() * 1e9)
msim_contacts.append({
'ContactID': head.id,
'Headline': '?', # Status
'Position': pos,
'GroupName': group,
'Visibility': 1,
'AvatarUrl': '',
'ShowAvatar': False, # No avatar yet xd
'LastLogin': last_login,
'IMName': head.username,
'NickName': head.username,
'NameSelect': 0, # 0 = nickname, 1 = email, 2 = email address
'OfflineMsg': '',
'SkyStatus': 0
})
self.send_persist_reply(cmd, dsn, lid, rid, msim_contacts)
# 1;2;6 - list all contact groups
case (1, 2, 6):
msim_groups = []
id = 0
for group in get_contact_groups(bs):
id += 1
msim_groups.append({
'GroupID': id,
'GroupName': group,
'Position': id,
'GroupFlag': 131073 # unclear what GroupFlag does, TODO(subpurple): figure it out
})
# self.send_persist_reply(cmd, dsn, lid, rid, msim_groups)
# 1;1;4 or 1;1;7 - look-up MySpaceIM-specific user info about yourself or by user ID
#
# 1;1;4 - should lookup self
# 1;1;7 - should lookup by given user ID
# - contains `UserID` field that 1;1;7 does not
case (1, 1, 4) | (1, 1, 7):
# default to our user id unless we are given a 1;1;4 persist message
user_id = self.bs.user.id
if lid == 7:
body_dict = unmarshal_msim_dict(body)
user_id = body_dict['UserID']
if (user := get_user_by_id(user_id)) is None:
pass
self.send_persist_reply(cmd, dsn, lid, rid, {
'UserID': user.id,
'Sound': True,
'!PrivacyMode': 0, # 0 = Anyone, 1 = Only people on my Contact List
'!ShowOnlyToList': False, # False = Anyone, True = Only people on my Contact List.
'!OfflineMessageMode': 0, # 0 = Everyone, 1 = Only people on my Contact List, 2 = No one.
'Headline': '',
'Avatarurl': '',
'Alert': '',
'!ShowAvatar': False,
'IMName': '', # No real way to get this right now so default
'!ClientVersion': 0, # No real way to get this right now so default
'!AllowBrowse': True,
'IMLang': 'English',
'LangID': 8192
})
# 1;4;3 or 1;4;5 - look-up MySpace user info by user ID
#
# 1;4;3 - should lookup by given user ID
# 1;4;5 - should lookup self
case (1, 4, 3) | (1, 4, 5):
body_dict = unmarshal_msim_dict(body)
user_id = body_dict['UserID']
if (user := get_user_by_id(user_id)) is None:
pass
# Since CrossTalk doesn't have a social media platform unlike the original MySpaceIM platform, we simply
# give default values to some of the keys here (e.g. BandName, SongName, Age, Gender, and Location).
self.send_persist_reply(cmd, dsn, lid, rid, {
'UserName': user.username,
'Email': user.email,
'UserID': user.id,
'ImageURL': '',
'DisplayName': user.username,
'BandName': '',
'SongName': '',
'Age': 0,
'Gender': 'M',
'Location': '',
'!TotalFriends': len(user.detail.contacts)
})
# 1;7;18 - query for social media (MySpace) notifications
case (1, 7, 18):
self.send_persist_reply(cmd, dsn, lid, rid, {
# 'Mail': 'On',
# 'BlogComment': 'Off',
# 'ProfileComment': 'Off',
# 'FriendRequest': 'Off',
# 'PictureComment': 'Off'
})
# 1;6;11 - network hyperlink request
case (1, 6, 11):
# e.g. Target=now.FriendID=1
#
# i'm not certain what the appropriate URLs to `Target` would be, but for now we send
# `TARGET_HOST`
body_dict = unmarshal_msim_dict(body)
target = body_dict['Target']
self.logger.info('Target:', target)
self.send_persist_reply(cmd, dsn, lid, rid, {
'WebTicket': TARGET_HOST
})
# 514;0;9 (2^512;0;9) - update contact info
case (514, 0, 9):
self.logger.info('Update contact info')
# 2;1;16 - undocumented
#
# however, based off of the command type (2 - reply), and client packet logs, it appears to be
# update group info
case (2, 2, 16):
self.send_persist_reply(cmd, dsn, lid, rid, '')
case _:
self.logger.info(f'Unknown persist command: {cmd};{dsn};{lid}')
def on_connect(self) -> None:
self.send_reply({
'lc': 1,
'nc': self.nonce,
'id': 1
})
def on_data_recieved(self, data: bytes, *, transport: Optional[asyncio.BaseTransport] = None) -> None:
if transport is None:
transport = self.transport
assert transport is not None
for m in self.reader.data_recieved(data):
# get command, which is always the first key
command = next(iter(m))
# run `_m_xxx` where xxx is the command if found in class, otherwise log that we didn't find it
try:
f = getattr(self, f'_m_{command}')
f(m)
except AttributeError:
self.logger.info('Invalid command:', command)
def send_reply(self, data_pairs: Dict[str, int | str | bytes | bool]) -> None:
self.writer.write(data_pairs)
transport = self.transport
if transport is not None:
transport.write(self.flush())
def send_persist_reply(self, cmd: int, dsn: int, lid: int, rid: int, body: Any) -> None:
self.send_reply({
'persistr': True,
'uid': self.bs.user.id,
'cmd': cmd ^ CommandBitFlag.CallbackReply,
'dsn': dsn,
'lid': lid,
'rid': rid,
'body': body
})
def send_persist_error(self, cmd: int, dsn: int, lid: int, rid: int, error_msg: str) -> None:
self.send_reply({
'persistr': True,
'cmd': cmd ^ CommandBitFlag.CallbackError,
'dsn': dsn,
'uid': self.bs.user.id,
'lid': lid,
'rid': rid,
'ErrorMessage': error_msg
})
async def send_keep_alive_periodically(self):
while True:
await asyncio.sleep(180)
self.send_reply({
'ka': True
})
def flush(self) -> bytes:
return self.writer.flush()
def close(self) -> None:
if self.closed:
return
self.closed = True
if self.close_callback:
self.close_callback()
if self.bs:
self.bs.close()
class BackendEventHandler(event.BackendEventHandler):
__slots__ = ('ctrl', 'bs')
ctrl: MSIMCtrl
def __init__(self, ctrl: MSIMCtrl) -> None:
self.ctrl = ctrl
def on_maintenance_message(self, *args: Any, **kwargs: Any) -> None:
self.ctrl.logger.info('on_maintenance message')
pass
def on_maintenance_boot(self) -> None:
self.ctrl.logger.info('on_maintenance_boot')
pass
def on_presence_notification(
self, ctc: Contact, on_contact_add: bool, old_substatus: Substatus, *,
trid: Optional[str] = None, update_status: bool = True, update_info_other: bool = True,
send_status_on_bl: bool = False, sess_id: Optional[int] = None,
updated_phone_info: Optional[Dict[str, Any]] = None,
) -> None:
self.ctrl.logger.info('on_presence_notification')
pass
def on_presence_self_notification(self, old_substatus: Substatus, *, update_status: bool = True,
update_info: bool = True) -> None:
self.ctrl.logger.info('on_presence_self_notification')
pass
def on_chat_invite(
self, chat: Chat, inviter: User, *, circle: bool = False, inviter_id: Optional[str] = None,
invite_msg: str = '',
) -> None:
self.ctrl.logger.info('on_chat_invite')
pass
def on_declined_chat_invite(self, chat: Chat, circle: bool = False) -> None:
self.ctrl.logger.info('on_declined_chat_invite')
pass
def on_added_me(self, user: User, *, adder_id: Optional[str] = None,
message: Optional[TextWithData] = None) -> None:
self.ctrl.logger.info('on_added_me')
pass
def on_removed_me(self, user: User) -> None:
self.ctrl.logger.info('on_removed_me')
pass
def on_contact_request_denied(self, user_added: User, message: Optional[str], *,
contact_id: Optional[str] = None) -> None:
self.ctrl.logger.info('on_contact_request_denied')
pass
def on_oim_sent(self, oim: OIM) -> None:
self.ctrl.logger.info('on_oim_sent')
pass
def on_login_elsewhere(self, option: LoginOption) -> None:
self.ctrl.logger.info('on_login_elsewhere')
pass
def on_circle_invite_revoked(self, chat_id: str) -> None:
self.ctrl.logger.info('on_circle_invite_revoked')
pass
def on_accepted_circle_invite(self, circle: Circle) -> None:
self.ctrl.logger.info('on_accepted_circle_invite')
pass
def on_circle_updated(self, circle: Circle) -> None:
self.ctrl.logger.info('on_circle_updated')
pass
def on_left_circle(self, circle: Circle) -> None:
self.ctrl.logger.info('on_left_circle')
pass
def on_circle_role_updated(self, chat_id: str, role: CircleRole) -> None:
self.ctrl.logger.info('on_circle_role_updated')
pass
def on_circle_created(self, circle: Circle) -> None:
self.ctrl.logger.info('on_circle_created')
pass
def on_close(self) -> None:
self.ctrl.close()
class MSIMReader:
__slots__ = ('_logger', '_buf')
_logger: Logger
_buf: bytes
def __init__(self, logger: Logger) -> None:
self._logger = logger
self._buf = b''
def data_recieved(self, data: bytes) -> Iterable[Dict[str, str]]:
self._buf += data
while self._buf:
m = self._read()
if m is None:
break
yield m
def _read(self) -> Optional[Dict[str, str]]:
# e.g.
# >>> \lc\1\nc\b'YFKltYx6p9Ve2YE+jX9aJjLxynwgzkdi+nOZqZZufXE='\id\1\final\
# <<< \login2\196610\username\toxidation@msn.com\response\bFKdo4E01WHpwimji5wdSgnjifhV7zv1mGg+ZzHSKqaO4bjV0uq9MzMU9Nk0UmzTFN+nI/u2KUf7fuM=\clientver\673\reconn\0\status\100\id\1\final\
#
# each message is terminated with \final\
try:
i = self._buf.index(b'\\final\\')
except (IndexError, ValueError):
return None
# extract message up to \final\`
data = self._buf[:i + len(b'\\final\\')].decode('utf-8')
# advance buffer to exclude `data`
self._buf = self._buf[i + len(b'\\final\\'):]
# log what we got
self._logger.debug('[Client]', data)
# split string by `\` and skip first member due to leading backslash
parts = data.split('\\')[1:]
# convert `parts` into dict by treating first member as key, second member as value, and so on
data_pairs = {}
for i in range(0, len(parts), 2):
key = parts[i]
value = parts[i + 1] if i + 1 < len(parts) else ""
data_pairs[key] = value
return data_pairs
class MSIMWriter:
__slots__ = ('_logger', '_buf')
_logger: Logger
_buf: io.BytesIO
def __init__(self, logger: Logger) -> None:
self._logger = logger
self._buf = io.BytesIO()
def write(self, data_pairs: Dict[str, Any]) -> None:
m = ''
for key, value in data_pairs.items():
m += f'\\{key}'
# skip over if value is bool and it isn't True
if isinstance(value, bool):
if not value:
continue
# otherwise, add \1 as the value pair
m += '\\1'
continue
# MySpaceIM dictionaries are e.g.
# k1=v2\x1ck2=v3
#
# which then deserialize to:
# k1: v2
# k2: v3
if isinstance(value, list):
m += '\\'
for d in value:
if isinstance(d, dict):
m += '\x1c'.join(f'{key}={value}' for key, value in d.items())
elif isinstance(value, dict):
m += '\\'
m += '\x1c'.join(f'{key}={value}' for key, value in value.items())
# encode `value` in base64 and add that as its value
elif isinstance(value, bytes):
b = base64.b64encode(value)
m += f'\\{b.decode('ascii')}'
# treat any other type (e.g. int or str) not seen above as a string
else:
m += f'\\{value}'
m += '\\final\\'
self._logger.debug('[Server]', m)
self._buf.write(m.encode('utf-8'))
def flush(self) -> bytes:
data = self._buf.getvalue()
if data:
self._buf = io.BytesIO()
return data