11
11
12
12
import io
13
13
import os
14
- import re
15
14
import sys
16
15
import socket
17
16
import struct
@@ -723,11 +722,11 @@ def PipeClient(address):
723
722
# Authentication stuff
724
723
#
725
724
726
- MESSAGE_LENGTH = 20
725
+ MESSAGE_LENGTH = 40 # MUST be > 20
727
726
728
- CHALLENGE = b'#CHALLENGE#'
729
- WELCOME = b'#WELCOME#'
730
- FAILURE = b'#FAILURE#'
727
+ _CHALLENGE = b'#CHALLENGE#'
728
+ _WELCOME = b'#WELCOME#'
729
+ _FAILURE = b'#FAILURE#'
731
730
732
731
# multiprocessing.connection Authentication Handshake Protocol Description
733
732
# (as documented for reference after reading the existing code)
@@ -751,7 +750,12 @@ def PipeClient(address):
751
750
# ------------------------------ ---------------------------------------
752
751
# 0. Open a connection on the pipe.
753
752
# 1. Accept connection.
754
- # 2. New random 20 bytes -> MESSAGE
753
+ # 2. Random 20+ bytes -> MESSAGE
754
+ # Modern servers always send
755
+ # more than 20 bytes and include
756
+ # a {digest} prefix on it with
757
+ # their preferred HMAC digest.
758
+ # Legacy ones send ==20 bytes.
755
759
# 3. send 4 byte length (net order)
756
760
# prefix followed by:
757
761
# b'#CHALLENGE#' + MESSAGE
@@ -764,14 +768,32 @@ def PipeClient(address):
764
768
# 6. Assert that M1 starts with:
765
769
# b'#CHALLENGE#'
766
770
# 7. Strip that prefix from M1 into -> M2
767
- # 8. Compute HMAC-MD5 of AUTHKEY, M2 -> C_DIGEST
771
+ # 7.1. Parse M2: if it is exactly 20 bytes in
772
+ # length this indicates a legacy server
773
+ # supporting only HMAC-MD5. Otherwise the
774
+ # 7.2. preferred digest is looked up from an
775
+ # expected "{digest}" prefix on M2. No prefix
776
+ # or unsupported digest? <- AuthenticationError
777
+ # 7.3. Put divined algorithm name in -> D_NAME
778
+ # 8. Compute HMAC-D_NAME of AUTHKEY, M2 -> C_DIGEST
768
779
# 9. Send 4 byte length prefix (net order)
769
780
# followed by C_DIGEST bytes.
770
- # 10. Compute HMAC-MD5 of AUTHKEY,
771
- # MESSAGE into -> M_DIGEST.
772
- # 11. Receive 4 or 4+8 byte length
781
+ # 10. Receive 4 or 4+8 byte length
773
782
# prefix (#4 dance) -> SIZE.
774
- # 12. Receive min(SIZE, 256) -> C_D.
783
+ # 11. Receive min(SIZE, 256) -> C_D.
784
+ # 11.1. Parse C_D: legacy servers
785
+ # accept it as is, "md5" -> D_NAME
786
+ # 11.2. modern servers check the length
787
+ # of C_D, IF it is 16 bytes?
788
+ # 11.2.1. "md5" -> D_NAME
789
+ # and skip to step 12.
790
+ # 11.3. longer? expect and parse a "{digest}"
791
+ # prefix into -> D_NAME.
792
+ # Strip the prefix and store remaining
793
+ # bytes in -> C_D.
794
+ # 11.4. Don't like D_NAME? <- AuthenticationError
795
+ # 12. Compute HMAC-D_NAME of AUTHKEY,
796
+ # MESSAGE into -> M_DIGEST.
775
797
# 13. Compare M_DIGEST == C_D:
776
798
# 14a: Match? Send length prefix &
777
799
# b'#WELCOME#'
@@ -797,97 +819,134 @@ def PipeClient(address):
797
819
# opening challenge message as an indicator of protocol version may work.
798
820
799
821
800
- _mac_algo_re = re .compile (
801
- rb'^{(?P<digestmod>(md5|sha256|sha384|sha3_256|sha3_384))}'
802
- rb'(?P<payload>.*)$'
803
- )
822
+ _ALLOWED_DIGESTS = frozenset (
823
+ {b'md5' , b'sha256' , b'sha384' , b'sha3_256' , b'sha3_384' })
824
+ _MAX_DIGEST_LEN = max (len (_ ) for _ in _ALLOWED_DIGESTS )
825
+
826
+ # Old hmac-md5 only server versions from Python <=3.11 sent a message of this
827
+ # length. It happens to not match the length of any supported digest so we can
828
+ # use a message of this length to indicate that we should work in backwards
829
+ # compatible md5-only mode without a {digest_name} prefix on our response.
830
+ _MD5ONLY_MESSAGE_LENGTH = 20
831
+ _MD5_DIGEST_LEN = 16
832
+ _LEGACY_LENGTHS = (_MD5ONLY_MESSAGE_LENGTH , _MD5_DIGEST_LEN )
833
+
834
+
835
+ def _get_digest_name_and_payload (message : bytes ) -> (str , bytes ):
836
+ """Returns a digest name and the payload for a response hash.
837
+
838
+ If a legacy protocol is detected based on the message length
839
+ or contents the digest name returned will be empty to indicate
840
+ legacy mode where MD5 and no digest prefix should be sent.
841
+ """
842
+ # modern message format: b"{digest}payload" longer than 20 bytes
843
+ # legacy message format: 16 or 20 byte b"payload"
844
+ if len (message ) in _LEGACY_LENGTHS :
845
+ # Either this was a legacy server challenge, or we're processing
846
+ # a reply from a legacy client that sent an unprefixed 16-byte
847
+ # HMAC-MD5 response. All messages using the modern protocol will
848
+ # be longer than either of these lengths.
849
+ return '' , message
850
+ if (message .startswith (b'{' ) and
851
+ (curly := message .find (b'}' , 1 , _MAX_DIGEST_LEN + 2 )) > 0 ):
852
+ digest = message [1 :curly ]
853
+ if digest in _ALLOWED_DIGESTS :
854
+ payload = message [curly + 1 :]
855
+ return digest .decode ('ascii' ), payload
856
+ raise AuthenticationError (
857
+ 'unsupported message length, missing digest prefix, '
858
+ f'or unsupported digest: { message = } ' )
804
859
805
860
806
861
def _create_response (authkey , message ):
807
862
"""Create a MAC based on authkey and message
808
863
809
864
The MAC algorithm defaults to HMAC-MD5, unless MD5 is not available or
810
- the message has a '{digestmod }' prefix. For legacy HMAC-MD5, the response
811
- is the raw MAC, otherwise the response is prefixed with '{digestmod }',
865
+ the message has a '{digest_name }' prefix. For legacy HMAC-MD5, the response
866
+ is the raw MAC, otherwise the response is prefixed with '{digest_name }',
812
867
e.g. b'{sha256}abcdefg...'
813
868
814
- Note: The MAC protects the entire message including the digestmod prefix.
869
+ Note: The MAC protects the entire message including the digest_name prefix.
815
870
"""
816
871
import hmac
817
- # message: {digest}payload, the MAC protects header and payload
818
- mo = _mac_algo_re .match (message )
819
- if mo is not None :
820
- digestmod = mo .group ('digestmod' ).decode ('ascii' )
821
- else :
822
- # old-style MD5 with fallback
823
- digestmod = None
824
-
825
- if digestmod is None :
872
+ digest_name = _get_digest_name_and_payload (message )[0 ]
873
+ # The MAC protects the entire message: digest header and payload.
874
+ if not digest_name :
875
+ # Legacy server without a {digest} prefix on message.
876
+ # Generate a legacy non-prefixed HMAC-MD5 reply.
826
877
try :
827
878
return hmac .new (authkey , message , 'md5' ).digest ()
828
879
except ValueError :
829
- # MD5 is not available, fall back to SHA2-256
830
- digestmod = 'sha256'
831
- prefix = b'{%s}' % digestmod .encode ('ascii' )
832
- return prefix + hmac .new (authkey , message , digestmod ).digest ()
880
+ # HMAC-MD5 is not available (FIPS mode?), fall back to
881
+ # HMAC-SHA2-256 modern protocol. The legacy server probably
882
+ # doesn't support it and will reject us anyways. :shrug:
883
+ digest_name = 'sha256'
884
+ # Modern protocol, indicate the digest used in the reply.
885
+ response = hmac .new (authkey , message , digest_name ).digest ()
886
+ return b'{%s}%s' % (digest_name .encode ('ascii' ), response )
833
887
834
888
835
889
def _verify_challenge (authkey , message , response ):
836
890
"""Verify MAC challenge
837
891
838
- If our message did not include a digestmod prefix, the client is allowed
839
- to select a stronger digestmod (HMAC-MD5 legacy to HMAC-SHA2-256) .
892
+ If our message did not include a digest_name prefix, the client is allowed
893
+ to select a stronger digest_name from _ALLOWED_DIGESTS .
840
894
841
895
In case our message is prefixed, a client cannot downgrade to a weaker
842
896
algorithm, because the MAC is calculated over the entire message
843
- including the '{digestmod }' prefix.
897
+ including the '{digest_name }' prefix.
844
898
"""
845
899
import hmac
846
- mo = _mac_algo_re .match (response )
847
- if mo is not None :
848
- # get digestmod from response.
849
- digestmod = mo .group ('digestmod' ).decode ('ascii' )
850
- mac = mo .group ('payload' )
851
- else :
852
- digestmod = 'md5'
853
- mac = response
900
+ response_digest , response_mac = _get_digest_name_and_payload (response )
901
+ response_digest = response_digest or 'md5'
854
902
try :
855
- expected = hmac .new (authkey , message , digestmod ).digest ()
903
+ expected = hmac .new (authkey , message , response_digest ).digest ()
856
904
except ValueError :
857
- raise AuthenticationError (f'unsupported digest { digestmod } ' )
858
- if not hmac .compare_digest (expected , mac ):
905
+ raise AuthenticationError (f'{ response_digest = } unsupported' )
906
+ if len (expected ) != len (response_mac ):
907
+ raise AuthenticationError (
908
+ f'expected { response_digest !r} of length { len (expected )} '
909
+ f'got { len (response_mac )} ' )
910
+ if not hmac .compare_digest (expected , response_mac ):
859
911
raise AuthenticationError ('digest received was wrong' )
860
- return True
861
912
862
913
863
- def deliver_challenge (connection , authkey , digestmod = None ):
914
+ def deliver_challenge (connection , authkey : bytes , digest_name = 'sha256' ):
864
915
if not isinstance (authkey , bytes ):
865
916
raise ValueError (
866
917
"Authkey must be bytes, not {0!s}" .format (type (authkey )))
918
+ assert MESSAGE_LENGTH > _MD5ONLY_MESSAGE_LENGTH , "protocol constraint"
867
919
message = os .urandom (MESSAGE_LENGTH )
868
- if digestmod is not None :
869
- message = b'{%s}%s' % (digestmod .encode ('ascii' ), message )
870
- connection .send_bytes (CHALLENGE + message )
920
+ message = b'{%s}%s' % (digest_name .encode ('ascii' ), message )
921
+ # Even when sending a challenge to a legacy client that does not support
922
+ # digest prefixes, they'll take the entire thing as a challenge and
923
+ # respond to it with a raw HMAC-MD5.
924
+ connection .send_bytes (_CHALLENGE + message )
871
925
response = connection .recv_bytes (256 ) # reject large message
872
926
try :
873
927
_verify_challenge (authkey , message , response )
874
928
except AuthenticationError :
875
- connection .send_bytes (FAILURE )
929
+ connection .send_bytes (_FAILURE )
876
930
raise
877
931
else :
878
- connection .send_bytes (WELCOME )
932
+ connection .send_bytes (_WELCOME )
933
+
879
934
880
- def answer_challenge (connection , authkey ):
935
+ def answer_challenge (connection , authkey : bytes ):
881
936
if not isinstance (authkey , bytes ):
882
937
raise ValueError (
883
938
"Authkey must be bytes, not {0!s}" .format (type (authkey )))
884
939
message = connection .recv_bytes (256 ) # reject large message
885
- assert message [:len (CHALLENGE )] == CHALLENGE , 'message = %r' % message
886
- message = message [len (CHALLENGE ):]
940
+ if not message .startswith (_CHALLENGE ):
941
+ raise AuthenticationError (
942
+ f'Protocol error, expected challenge: { message = } ' )
943
+ message = message [len (_CHALLENGE ):]
944
+ if len (message ) < _MD5ONLY_MESSAGE_LENGTH :
945
+ raise AuthenticationError ('challenge too short: {len(message)} bytes' )
887
946
digest = _create_response (authkey , message )
888
947
connection .send_bytes (digest )
889
948
response = connection .recv_bytes (256 ) # reject large message
890
- if response != WELCOME :
949
+ if response != _WELCOME :
891
950
raise AuthenticationError ('digest sent was rejected' )
892
951
893
952
#
0 commit comments