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