@@ -723,39 +723,227 @@ def PipeClient(address):
723723# Authentication stuff
724724#
725725
726- MESSAGE_LENGTH = 20
726+ MESSAGE_LENGTH = 40 # MUST be > 20
727727
728- CHALLENGE = b'#CHALLENGE#'
729- WELCOME = b'#WELCOME#'
730- FAILURE = b'#FAILURE#'
728+ _CHALLENGE = b'#CHALLENGE#'
729+ _WELCOME = b'#WELCOME#'
730+ _FAILURE = b'#FAILURE#'
731731
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+ """
733868 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' ):
734912 if not isinstance (authkey , bytes ):
735913 raise ValueError (
736914 "Authkey must be bytes, not {0!s}" .format (type (authkey )))
915+ assert MESSAGE_LENGTH > _MD5ONLY_MESSAGE_LENGTH , "protocol constraint"
737916 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 )
740922 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
743928 else :
744- connection .send_bytes (FAILURE )
745- raise AuthenticationError ('digest received was wrong' )
929+ connection .send_bytes (_WELCOME )
746930
747- def answer_challenge ( connection , authkey ):
748- import hmac
931+
932+ def answer_challenge ( connection , authkey : bytes ):
749933 if not isinstance (authkey , bytes ):
750934 raise ValueError (
751935 "Authkey must be bytes, not {0!s}" .format (type (authkey )))
752936 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 )
756944 connection .send_bytes (digest )
757945 response = connection .recv_bytes (256 ) # reject large message
758- if response != WELCOME :
946+ if response != _WELCOME :
759947 raise AuthenticationError ('digest sent was rejected' )
760948
761949#
0 commit comments