Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client
Submodule client updated 1 files
+1 −1 src/uds/rest.py
192 changes: 190 additions & 2 deletions server/src/uds/REST/methods/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import base64
import logging
import re
import struct
import typing

from django.urls import reverse
from django.utils.translation import gettext as _

from cryptography import x509 as crypto_x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization.pkcs7 import PKCS7Options, PKCS7SignatureBuilder

from uds import models
from uds.core import consts, exceptions, types
from uds.core.managers.crypto import CryptoManager
Expand All @@ -51,6 +59,152 @@
CLIENT_VERSION: typing.Final[str] = consts.system.VERSION
LOG_ENABLED_DURATION: typing.Final[int] = 2 * 60 * 60 * 24 # 2 days

# Settings that Microsoft's RDP client includes in signature verification.
# Order matters: it matches the order expected by mstsc.exe.
_RDP_SECURE_SETTINGS: typing.Final[list[tuple[str, str]]] = [
('full address:s:', 'Full Address'),
('alternate full address:s:', 'Alternate Full Address'),
('pcb:s:', 'PCB'),
('use redirection server name:i:', 'Use Redirection Server Name'),
('server port:i:', 'Server Port'),
('negotiate security layer:i:', 'Negotiate Security Layer'),
('enablecredsspsupport:i:', 'EnableCredSspSupport'),
('disableconnectionsharing:i:', 'DisableConnectionSharing'),
('autoreconnection enabled:i:', 'AutoReconnection Enabled'),
('gatewayhostname:s:', 'GatewayHostname'),
('gatewayusagemethod:i:', 'GatewayUsageMethod'),
('gatewayprofileusagemethod:i:', 'GatewayProfileUsageMethod'),
('gatewaycredentialssource:i:', 'GatewayCredentialsSource'),
('support url:s:', 'Support URL'),
('promptcredentialonce:i:', 'PromptCredentialOnce'),
('require pre-authentication:i:', 'Require pre-authentication'),
('pre-authentication server address:s:', 'Pre-authentication server address'),
('alternate shell:s:', 'Alternate Shell'),
('shell working directory:s:', 'Shell Working Directory'),
('remoteapplicationprogram:s:', 'RemoteApplicationProgram'),
('remoteapplicationexpandworkingdir:s:', 'RemoteApplicationExpandWorkingdir'),
('remoteapplicationmode:i:', 'RemoteApplicationMode'),
('remoteapplicationguid:s:', 'RemoteApplicationGuid'),
('remoteapplicationname:s:', 'RemoteApplicationName'),
('remoteapplicationicon:s:', 'RemoteApplicationIcon'),
('remoteapplicationfile:s:', 'RemoteApplicationFile'),
('remoteapplicationfileextensions:s:', 'RemoteApplicationFileExtensions'),
('remoteapplicationcmdline:s:', 'RemoteApplicationCmdLine'),
('remoteapplicationexpandcmdline:s:', 'RemoteApplicationExpandCmdLine'),
('prompt for credentials:i:', 'Prompt For Credentials'),
('authentication level:i:', 'Authentication Level'),
('audiomode:i:', 'AudioMode'),
('redirectdrives:i:', 'RedirectDrives'),
('redirectprinters:i:', 'RedirectPrinters'),
('redirectcomports:i:', 'RedirectCOMPorts'),
('redirectsmartcards:i:', 'RedirectSmartCards'),
('redirectposdevices:i:', 'RedirectPOSDevices'),
('redirectclipboard:i:', 'RedirectClipboard'),
('devicestoredirect:s:', 'DevicesToRedirect'),
('drivestoredirect:s:', 'DrivesToRedirect'),
('loadbalanceinfo:s:', 'LoadBalanceInfo'),
('redirectdirectx:i:', 'RedirectDirectX'),
('rdgiskdcproxy:i:', 'RDGIsKDCProxy'),
('kdcproxyname:s:', 'KDCProxyName'),
('eventloguploadaddress:s:', 'EventLogUploadAddress'),
]


def _sign_rdp_content(rdp_content: str) -> str:
"""Sign an RDP file content using the configured server certificate.

Replicates exactly what Microsoft's rdpsign.exe does:
1. Extract the subset of settings covered by the signature.
2. Build a UTF-16LE message blob (identical to what mstsc.exe verifies).
3. Sign with CMS/PKCS#7 detached signature (DER, no attributes, no SMIME caps).
4. Prepend the 12-byte Microsoft header and base64-encode the result.
5. Append signscope and signature lines to the RDP content.

Args:
rdp_content: The RDP file content as a Python string (any line-ending style).

Returns:
Signed RDP content as a string with \\r\\n line endings.

Raises:
Exception: If signing is not configured or no signable settings are found.
"""
sign_cert_pem = GlobalConfig.RDP_SIGN_CERT.as_str().strip()
sign_key_pem = GlobalConfig.RDP_SIGN_KEY.as_str().strip()

if not sign_cert_pem or not sign_key_pem:
raise Exception('RDP signing certificate/key not configured in Security settings')

cert = crypto_x509.load_pem_x509_certificate(sign_cert_pem.encode(), default_backend())
private_key = serialization.load_pem_private_key(sign_key_pem.encode(), password=None, backend=default_backend())

# Optional intermediate chain
chain_certs: list[crypto_x509.Certificate] = []
chain_pem = GlobalConfig.RDP_SIGN_CHAIN.as_str().strip()
if chain_pem:
for block in re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', chain_pem, re.DOTALL):
chain_certs.append(crypto_x509.load_pem_x509_certificate(block.encode(), default_backend()))

# Normalise line endings and strip each line
lines = [line.strip() for line in rdp_content.replace('\r\n', '\n').replace('\r', '\n').split('\n')]

settings: list[str] = []
fulladdress: typing.Optional[str] = None
alternatefulladdress: typing.Optional[str] = None

for v in lines:
if not v:
continue
if v.startswith('full address:s:'):
fulladdress = v[15:]
elif v.startswith('alternate full address:s:'):
alternatefulladdress = v[25:]
elif v.startswith('signature:s:') or v.startswith('signscope:s:'):
continue # Strip any pre-existing signature/signscope
settings.append(v)

# Prevent address-spoofing via alternate full address
if fulladdress and not alternatefulladdress:
settings.append('alternate full address:s:' + fulladdress)

# Collect only the settings covered by the signature, preserving order
signnames: list[str] = []
signlines: list[str] = []
for prefix, name in _RDP_SECURE_SETTINGS:
for v in settings:
if v.startswith(prefix):
signnames.append(name)
signlines.append(v)
break # Only first match per setting key

if not signnames:
raise Exception('No signable settings found in RDP content')

# Build the message blob exactly as mstsc.exe expects: UTF-16LE, CRLF, NUL-terminated
msgtext = '\r\n'.join(signlines) + '\r\n' + 'signscope:s:' + ','.join(signnames) + '\r\n' + '\x00'
msgblob = msgtext.encode('UTF-16LE')

# Sign: equivalent to: openssl smime -sign -binary -noattr -nosmimecap -outform DER
# Note: NoAttributes is a superset of NoCapabilities in the Python API.
builder = PKCS7SignatureBuilder().set_data(msgblob).add_signer(cert, private_key, hashes.SHA256())
for chain_cert in chain_certs:
builder = builder.add_certificate(chain_cert)
sig_der = builder.sign(
serialization.Encoding.DER,
[PKCS7Options.Binary, PKCS7Options.NoAttributes],
)

# Prepend the 12-byte Microsoft header (two unknown DWORDs + length DWORD)
msgsig = struct.pack('<III', 0x00010001, 0x00000001, len(sig_der)) + sig_der
sigval = base64.b64encode(msgsig).decode('ascii')

# Reassemble: original settings + signscope + signature, CRLF line endings
result = '\r\n'.join(settings)
result += '\r\nsignscope:s:' + ','.join(signnames)
result += '\r\nsignature:s:' + sigval
result += '\r\n'
return result


# Enclosed methods under /client path
class Client(Handler):
Expand Down Expand Up @@ -134,8 +288,13 @@ def process(self, ticket: str, scrambler: str) -> dict[str, typing.Any]:
return Client.result(error=types.errors.Error.ACCESS_DENIED)

if scrambler == "rdp_signature":
# TODO: Signt and return the RDP signed by our server cert
pass
# Return signing certificate info so the client can install it in the trust store.
# The actual RDP signing is done via POST (see post() handler).
sign_cert = GlobalConfig.RDP_SIGN_CERT.as_str().strip()
chain = GlobalConfig.RDP_SIGN_CHAIN.as_str().strip()
if not sign_cert:
return Client.result(error='RDP signing not configured')
return Client.result(result={'cert': sign_cert, 'chain': chain})

self._request.user = User.objects.get(uuid=data['user'])

Expand Down Expand Up @@ -192,6 +351,21 @@ def process(self, ticket: str, scrambler: str) -> dict[str, typing.Any]:
validity=60 * 60 * 24,
)

# Always create a short-lived signing ticket so transport scripts can
# request server-side RDP signing via POST /{sign_ticket}/rdp_signature.
sign_ticket = TicketStore.create(
{
'user': self._request.user.uuid,
'userservice': info.userservice.uuid,
'type': 'rdp_sign',
},
validity=60 * 60, # 1 hour – enough for any reasonable session
)
# Inject into transport parameters so scripts can access sp['sign_ticket']
params_with_sign = dict(transport_script.parameters)
params_with_sign['sign_ticket'] = sign_ticket
transport_script.parameters = params_with_sign

return Client.result(
result={
'script': transport_script.script,
Expand Down Expand Up @@ -233,6 +407,20 @@ def post(self) -> dict[str, typing.Any]:

self._request.user = User.objects.get(uuid=data['user'])

# Handle rdp_signature before we require a userservice
if command == 'rdp_signature':
if data.get('type') != 'rdp_sign':
return Client.result(error='Invalid ticket type for RDP signing')
rdp_content: str = self._params.get('rdp', '')
if not rdp_content:
return Client.result(error='Missing RDP content')
try:
signed = _sign_rdp_content(rdp_content)
return Client.result(result=signed)
except Exception as e:
logger.exception('RDP signing failed')
return Client.result(error=str(e))

try:
userservice = models.UserService.objects.get(uuid=data['userservice'])
except models.UserService.DoesNotExist:
Expand Down
22 changes: 21 additions & 1 deletion server/src/uds/core/util/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,27 @@ class GlobalConfig:
type=Config.FieldType.TEXT,
help=_('URL to leave cookies consent'),
)


# RDP file signing
RDP_SIGN_CERT: Config.Value = Config.section(Config.SectionType.SECURITY).value(
'RDP Signing Certificate',
'',
type=Config.FieldType.LONGTEXT,
help=_('PEM-encoded certificate used to sign .rdp files (leaf certificate)'),
)
RDP_SIGN_KEY: Config.Value = Config.section(Config.SectionType.SECURITY).value(
'RDP Signing Private Key',
'',
type=Config.FieldType.LONGTEXT,
help=_('PEM-encoded private key corresponding to the RDP signing certificate'),
)
RDP_SIGN_CHAIN: Config.Value = Config.section(Config.SectionType.SECURITY).value(
'RDP Signing Certificate Chain',
'',
type=Config.FieldType.LONGTEXT,
help=_('PEM-encoded intermediate certificate chain for RDP signing (optional, omit for self-signed certs)'),
)


@staticmethod
def is_initialized() -> bool:
Expand Down
3 changes: 2 additions & 1 deletion server/src/uds/transports/RDP/scripts/windows/direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
# The password must be encoded, to be included in a .rdp file, as 'UTF-16LE' before protecting (CtrpyProtectData) it in order to work with mstsc
theFile = sp['as_file'].format(password=password) # type: ignore

theFile = tools.sign_rdp(theFile)
if sp.get('sign_ticket'): # type: ignore
theFile = tools.sign_rdp(theFile, api, sp['sign_ticket']) # type: ignore

filename = tools.saveTempFile(theFile)

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b0NrFPNRgkQmGycaL/gUhFKShW34N3Yto33JyDT9ructKOTEzT8qCEnvp5ypb0vwZQBhCfya0ExGDO77DdRPb2QAvtQylPxaX+D7FdLAKZO8WOw+clCJGJHFlpiAi7a1lY0ve6dfJotu47vNppmOy5RGO1Iz9FMQuOpq0xNXcrGz9I5zez47Se0FkhU1XlYgrrI8uexQqc8faz+nw6fJE4ADnfqo+b6mJmRIm7gbE9VyMZz6NR65zqtcyOWgmRrDOO3w6dirEYOIES2GFfZXOl4L+5bIDTVbtrYGoTtPIgmom8fjFfOP2qWAhjQ7jsjDqC0pPshOlNqB4FyORoAEzQ10yt53bPJHaOe/9uzW75THNGCj8AVntzbLGDghdJG49Yv9gAxJPFpdkGhtesy92Q0pryDjtTtLBtTyWvj9iCpUremYp71tROFHdEY40ypG7YDmDHNdkK6vz99MsFwHpcjs9XnHAJlaJHy96FdI6dHBC4ePlaJSVABOb9SS74WyYVB/VOF6bZ55mbvD7XpzzsG7fk/JV6If047tULGnCdWJCvOZ05rI0H1nUJAwgg42VmOKxNKJnBKdP0hVPuvRg2L2pNDioocXxnXvYfUWBr6bq/6Vkv/qrkkkWy+XMhTSGD9nwskhpFdOMNfjeelr50bSGcl2QGzEO2SnKzrfdTo=
oSbVri3AmjaK9ENnCz/AEOKHiAy4aHID3K/oeAvFbW/VFw9Uu/519EWdixUsJWXi/H5ICQXIVW3fZsWImIsGx7bnymNDtSCTHTsYq35aExYm8h19wxFNdQlFOaNkwdRBF4Glj7V3FWdElIyoe+V2ciqhBiaBTMIBLtoePf6i45OpQd1OoHffAQfkB73fR9D/z4CANlllLXdq39sAFxtqQBIMMQbaEZAbNEJ1RwLVtpGq90tC/2TEZqg+yvV6UKA2NzsELQOL2dN0QwCK5FRLZsxZGGXn4Uh7rQNZNlTWcC53thzBG1Ji6Xm3psyeocSM0TFQNMHY7j6KDK8z8JP5A/jU1RWwW46MpJkfEykCZXDAkWKg/KrZcEpz80C1/yqEmBcAvvdR9W7xZkwgTk60WOm7a8gYNt1fEKMetmW2cZhWQrHTK7uD6jro1of43XmuMZZCwiVvk7MaVxn0AwYerIT4TS2hltWV8iWPVL7zcymsvYXT5unfPEWqp2ZQylVIkOiGrTRh31jPWGdeJbfMpbMIiluyy855Wy49aNVT5Phc+YRUVqZS0lQerafBenC/lvotBg4Jn9OWmHiLkwes8L54bIq91c163z4EzkzQhhBTN4SKSN8KQcZdu78EsbQIOyMZd1JGbtQWq8CFT3ER2KykB484FBlesnxxEn4jbGo=
3 changes: 2 additions & 1 deletion server/src/uds/transports/RDP/scripts/windows/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
password=password, address='127.0.0.1:{}'.format(fs.server_address[1])
)

theFile = tools.sign_rdp(theFile)
if sp.get('sign_ticket'): # type: ignore
theFile = tools.sign_rdp(theFile, api, sp['sign_ticket']) # type: ignore

filename = tools.saveTempFile(theFile)
executable = tools.findApp('mstsc.exe')
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
kG2nEc2XkOI45ag2onfRbTovNND1L5CMWi7XI6+S2Rl35A64NBhDJL5sr3yKh6nLMQ+xPE7aYARPJ15tzboR9LG5fUNcubwHqtexT/NBc6eV2tEbGskSCutpwh7lgyqb2nH4B51u0JIoJbKDuF09L+wk/yWJrTvNBfjW7RZ19f9zxBYO0MCoDl3pnBgX8IRn0wyu84PhY+78NOodOtAx3DuYlsa4i5aQ4Wq2bFsMGjS+gfd43ybn0gQIPhC9U/6QQ3Jeh49Ylw4p8iqdr/FDLsbSyvDh3cTKPP/kkaQfL/muqCXm7X3eUx434aoHM15NY3F7kNsUu34tX9ZL5wwivlf3kq09ygrkZUddPEt9/8YThHbPD5OpBeuYD30ofoYKQidlVj/w8uonPPo9cDpOxf26k673J53I24J2sD2yQ/7ouH4aXAsNTIVCd5iCSZuyOGf+6UArMW20SPNORaNwh0qdts8LumQo0latWgbphVKbtM/2XQA/v+g43olplzGJjlogVYC6L3E78L+1OjXcqXdGCcC/9y07M65sbDIH6X6ertP73daaZa8kHiFwJV+KFpaKLC1KeicgVV1rCb3VxcYkIlLIw0lXH7XQb2vMG9Aqag5JaYOyE3EKay8Ec/uxxow4w32EMGBVWd6VPUb/3NB3JcmjwY/DwgJUcdzJ6O4=
VuCtUDBoBGqtF64yAKZwHfkFyTsUp6LbUntVOxa6tTeGnhC/qjiO4n/7gB4NvlYf480LMowOXFGTBb655b96XBMwj/iZSNcPMPwpcou5jVJg5Tyq4u3E98ewvb4hXvmfV0HWK8LQgXWMO0cWkuC4Bv8OsETbzeY2z+MVOvxaiX4SPjVDGQb7zCvJxd6k82ltYQcA7q/LWJMReCDVEoHded4aoK6l64ncUP89CXmHu5rSvH78wb0hJBnWwSjJ3o7yrekM4+OYXiiB9YXft/0+OFbZVf2FbWQm1r9D186rlpuQ+vY001gdhqkleZy891YlKwR1RXPrOZkYM4/8Fqv0KCQX4k3w3p9sg7rWTHNiedIzebwE5pxHvhsZUz6vxYJIr+RSudt5GlOm5pNsIeUW6RV2hHqjOnFAnpf3fGb81j3rDudBUvK/BsonU2rLTA1APC0vaYH3ByyGOoWLGWELXlbVLNoBgXDBevMGFc7WrohTgbyJH5CR25U5llJqtZQHBAfF+1ArcQbsfW2C5oQY/lLGtSPLs2gk+LV9QVIfMwLHmkWGA+p+6n8nSTEud8sCWXiR6+45st/KNIykRwPW/TjQd8Dj1f+A1AUCwKc4OpnTlpiLkfPkfodwy5FEK7jxmGWgr1+Ehj2jnyQ4t9T847cSHqxhv3LGlnCPaFzqY5c=
Loading