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