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