forked from auracaster/bumble_mirror
add classic pairing io delegation
This commit is contained in:
138
bumble/device.py
138
bumble/device.py
@@ -292,7 +292,18 @@ def with_connection_from_handle(function):
|
||||
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||
logger.warn(f'no connection found for handle 0x{connection_handle:04X}')
|
||||
return
|
||||
function(self, connection, *args, **kwargs)
|
||||
return function(self, connection, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# Decorator that converts the first argument from a bluetooth address to a connection
|
||||
def with_connection_from_address(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(self, address, *args, **kwargs):
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address == address:
|
||||
return function(self, connection, *args, **kwargs)
|
||||
logger.warn(f'no connection found for address {address}')
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -453,6 +464,11 @@ class Device(CompositeEventEmitter):
|
||||
if connection := self.connections.get(connection_handle):
|
||||
return connection
|
||||
|
||||
def find_connection_by_bd_addr(self, bd_addr):
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address == bd_addr:
|
||||
return connection
|
||||
|
||||
def register_l2cap_server(self, psm, server):
|
||||
self.l2cap_channel_manager.register_server(psm, server)
|
||||
|
||||
@@ -936,13 +952,13 @@ class Device(CompositeEventEmitter):
|
||||
# Set up event handlers
|
||||
pending_authentication = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_authentication_complete():
|
||||
def on_authentication():
|
||||
pending_authentication.set_result(None)
|
||||
|
||||
def on_authentication_failure(error):
|
||||
pending_authentication.set_exception(error)
|
||||
def on_authentication_failure(error_code):
|
||||
pending_authentication.set_exception(HCI_Error(error_code))
|
||||
|
||||
connection.on('connection_authentication_complete', on_authentication_complete)
|
||||
connection.on('connection_authentication', on_authentication)
|
||||
connection.on('connection_authentication_failure', on_authentication_failure)
|
||||
|
||||
# Request the authentication
|
||||
@@ -957,7 +973,7 @@ class Device(CompositeEventEmitter):
|
||||
# Wait for the authentication to complete
|
||||
await pending_authentication
|
||||
finally:
|
||||
connection.remove_listener('connection_authentication_complete', on_authentication_complete)
|
||||
connection.remove_listener('connection_authentication', on_authentication)
|
||||
connection.remove_listener('connection_authentication_failure', on_authentication_failure)
|
||||
|
||||
async def encrypt(self, connection):
|
||||
@@ -1141,10 +1157,10 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_connection_authentication_complete(self, connection):
|
||||
logger.debug(f'*** Connection Authentication Complete: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}')
|
||||
def on_connection_authentication(self, connection):
|
||||
logger.debug(f'*** Connection Authentication: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}')
|
||||
connection.authenticated = True
|
||||
connection.emit('connection_authentication_complete')
|
||||
connection.emit('connection_authentication')
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
@@ -1152,6 +1168,110 @@ class Device(CompositeEventEmitter):
|
||||
logger.debug(f'*** Connection Authentication Failure: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}, error={error}')
|
||||
connection.emit('connection_authentication_failure', error)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_authentication_io_capability_request(self, connection):
|
||||
# Ask what the pairing config should be for this connection
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
# Map the SMP IO capability to a Classic IO capability
|
||||
if (io_capability := {
|
||||
smp.SMP_DISPLAY_ONLY_IO_CAPABILITY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
smp.SMP_DISPLAY_YES_NO_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY
|
||||
}.get(pairing_config.delegate.io_capability)) is None:
|
||||
logger.warning(f'cannot map IO capability ({pairing_config.delegate.io_capability}')
|
||||
io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
||||
|
||||
# Compute the authentication requirements
|
||||
authentication_requirements = (
|
||||
(
|
||||
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS
|
||||
),
|
||||
(
|
||||
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS
|
||||
)
|
||||
)[1 if pairing_config.bonding else 0][1 if pairing_config.mitm else 0]
|
||||
|
||||
# Respond
|
||||
self.host.send_command_sync(
|
||||
HCI_IO_Capability_Request_Reply_Command(
|
||||
bd_addr = connection.peer_address,
|
||||
io_capability = io_capability,
|
||||
oob_data_present = 0x00,
|
||||
authentication_requirements = authentication_requirements
|
||||
)
|
||||
)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_authentication_user_confirmation_request(self, connection, code):
|
||||
# Ask what the pairing config should be for this connection
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
can_confirm = pairing_config.delegate.io_capability not in {
|
||||
smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
smp.SMP_DISPLAY_ONLY_IO_CAPABILITY
|
||||
}
|
||||
|
||||
# Respond
|
||||
if can_confirm and pairing_config.delegate:
|
||||
async def compare_numbers():
|
||||
numbers_match = await pairing_config.delegate.compare_numbers(code, digits=6)
|
||||
if numbers_match:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
else:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Confirmation_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
|
||||
asyncio.create_task(compare_numbers())
|
||||
else:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_authentication_user_passkey_request(self, connection):
|
||||
# Ask what the pairing config should be for this connection
|
||||
pairing_config = self.pairing_config_factory(connection)
|
||||
|
||||
can_input = pairing_config.delegate.io_capability in {
|
||||
smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
|
||||
}
|
||||
|
||||
# Respond
|
||||
if can_input and pairing_config.delegate:
|
||||
async def get_number():
|
||||
number = await pairing_config.delegate.get_number()
|
||||
if number is not None:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Passkey_Request_Reply_Command(
|
||||
bd_addr = connection.peer_address,
|
||||
numeric_value = number)
|
||||
)
|
||||
else:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Passkey_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
|
||||
asyncio.create_task(get_number())
|
||||
else:
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Passkey_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_connection_encryption_change(self, connection, encryption):
|
||||
|
||||
@@ -258,6 +258,9 @@ HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND = hci_command_
|
||||
HCI_READ_CLOCK_OFFSET_COMMAND = hci_command_op_code(0x01, 0x001F)
|
||||
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002B)
|
||||
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002C)
|
||||
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND = hci_command_op_code(0x01, 0x002D)
|
||||
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002E)
|
||||
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND = hci_command_op_code(0x01, 0x002F)
|
||||
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND = hci_command_op_code(0x01, 0x003D)
|
||||
HCI_SNIFF_MODE_COMMAND = hci_command_op_code(0x02, 0x0003)
|
||||
HCI_EXIT_SNIFF_MODE_COMMAND = hci_command_op_code(0x02, 0x0004)
|
||||
@@ -407,6 +410,9 @@ HCI_COMMAND_NAMES = {
|
||||
HCI_READ_CLOCK_OFFSET_COMMAND: 'HCI_READ_CLOCK_OFFSET_COMMAND',
|
||||
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND: 'HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND',
|
||||
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND: 'HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND',
|
||||
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND: 'HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND',
|
||||
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND: 'HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND',
|
||||
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND: 'HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND',
|
||||
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND: 'HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND',
|
||||
HCI_SNIFF_MODE_COMMAND: 'HCI_SNIFF_MODE_COMMAND',
|
||||
HCI_EXIT_SNIFF_MODE_COMMAND: 'HCI_EXIT_SNIFF_MODE_COMMAND',
|
||||
@@ -683,20 +689,20 @@ HCI_IO_CAPABILITY_NAMES = {
|
||||
}
|
||||
|
||||
# Authentication Requirements
|
||||
HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x00
|
||||
HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x01
|
||||
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x02
|
||||
HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x03
|
||||
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x04
|
||||
HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x05
|
||||
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS = 0x00
|
||||
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS = 0x01
|
||||
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS = 0x02
|
||||
HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS = 0x03
|
||||
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS = 0x04
|
||||
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS = 0x05
|
||||
|
||||
HCI_AUTHENTICATION_REQUIREMENTS_NAMES = {
|
||||
HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS'
|
||||
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS',
|
||||
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS'
|
||||
}
|
||||
|
||||
# Link Key Types
|
||||
@@ -1449,6 +1455,55 @@ class HCI_User_Confirmation_Request_Reply_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
('bd_addr', Address.parse_address)
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('bd_addr', Address.parse_address)
|
||||
]
|
||||
)
|
||||
class HCI_User_Confirmation_Request_Negative_Reply_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.1.31 User Confirmation Request Negative Reply Command
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
('bd_addr', Address.parse_address),
|
||||
('numeric_value', 4)
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('bd_addr', Address.parse_address)
|
||||
]
|
||||
)
|
||||
class HCI_User_Passkey_Request_Reply_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.1.32 User Passkey Request Reply Command
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
('bd_addr', Address.parse_address)
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('bd_addr', Address.parse_address)
|
||||
]
|
||||
)
|
||||
class HCI_User_Passkey_Request_Negative_Reply_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.1.33 User Passkey Request Negative Reply Command
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command([
|
||||
('connection_handle', 2),
|
||||
@@ -3367,6 +3422,16 @@ class HCI_User_Confirmation_Request_Event(HCI_Event):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Event.event([
|
||||
('bd_addr', Address.parse_address)
|
||||
])
|
||||
class HCI_User_Passkey_Request_Event(HCI_Event):
|
||||
'''
|
||||
See Bluetooth spec @ 7.7.43 User Passkey Request Event
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Event.event([
|
||||
('status', STATUS_SPEC),
|
||||
|
||||
@@ -85,6 +85,7 @@ class Host(EventEmitter):
|
||||
self.command_semaphore = asyncio.Semaphore(1)
|
||||
self.long_term_key_provider = None
|
||||
self.link_key_provider = None
|
||||
self.pairing_io_capability_provider = None # Classic only
|
||||
|
||||
# Connect to the source and sink if specified
|
||||
if controller_source:
|
||||
@@ -495,7 +496,7 @@ class Host(EventEmitter):
|
||||
def on_hci_authentication_complete_event(self, event):
|
||||
# Notify the client
|
||||
if event.status == HCI_SUCCESS:
|
||||
self.emit('connection_authentication_complete', event.connection_handle)
|
||||
self.emit('connection_authentication', event.connection_handle)
|
||||
else:
|
||||
self.emit('connection_authentication_failure', event.connection_handle, event.status)
|
||||
|
||||
@@ -560,26 +561,16 @@ class Host(EventEmitter):
|
||||
asyncio.create_task(send_link_key())
|
||||
|
||||
def on_hci_io_capability_request_event(self, event):
|
||||
# For now, just return NoInputNoOutput and no MITM
|
||||
# TODO: delegate the decision
|
||||
self.send_command_sync(
|
||||
HCI_IO_Capability_Request_Reply_Command(
|
||||
bd_addr = event.bd_addr,
|
||||
io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
oob_data_present = 0x00,
|
||||
authentication_requirements = 0x00 # 0x02 # FIXME: testing only
|
||||
)
|
||||
)
|
||||
self.emit('authentication_io_capability_request', event.bd_addr)
|
||||
|
||||
def on_hci_io_capability_response_event(self, event):
|
||||
pass
|
||||
|
||||
def on_hci_user_confirmation_request_event(self, event):
|
||||
# For now, just confirm everything
|
||||
# TODO: delegate the decision
|
||||
self.send_command_sync(
|
||||
HCI_User_Confirmation_Request_Reply_Command(bd_addr = event.bd_addr)
|
||||
)
|
||||
self.emit('authentication_user_confirmation_request', event.bd_addr, event.numeric_value)
|
||||
|
||||
def on_hci_user_passkey_request_event(self, event):
|
||||
self.emit('authentication_user_passkey_request', event.bd_addr)
|
||||
|
||||
def on_hci_inquiry_complete_event(self, event):
|
||||
self.emit('inquiry_complete')
|
||||
|
||||
@@ -464,13 +464,13 @@ class PairingDelegate:
|
||||
async def accept(self):
|
||||
return True
|
||||
|
||||
async def compare_numbers(self, number):
|
||||
async def compare_numbers(self, number, digits=6):
|
||||
return True
|
||||
|
||||
async def get_number(self):
|
||||
return 0
|
||||
|
||||
async def display_number(self, number):
|
||||
async def display_number(self, number, digits=6):
|
||||
pass
|
||||
|
||||
|
||||
@@ -699,7 +699,7 @@ class Session:
|
||||
async def prompt():
|
||||
logger.debug(f'verification code: {code}')
|
||||
try:
|
||||
response = await self.pairing_config.delegate.compare_numbers(code)
|
||||
response = await self.pairing_config.delegate.compare_numbers(code, digits=6)
|
||||
if response:
|
||||
next_steps()
|
||||
return
|
||||
@@ -733,7 +733,7 @@ class Session:
|
||||
self.tk = self.passkey.to_bytes(16, byteorder='little')
|
||||
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
||||
|
||||
asyncio.create_task(self.pairing_config.delegate.display_number(self.passkey))
|
||||
asyncio.create_task(self.pairing_config.delegate.display_number(self.passkey, digits=6))
|
||||
|
||||
def input_passkey(self, next_steps=None):
|
||||
# Prompt the user for the passkey displayed on the peer
|
||||
|
||||
Reference in New Issue
Block a user