Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-asyncssh for openSUSE:Factory checked in at 2023-10-05 20:04:58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-asyncssh (Old) and /work/SRC/openSUSE:Factory/.python-asyncssh.new.28202 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-asyncssh" Thu Oct 5 20:04:58 2023 rev:24 rq:1115789 version:2.14.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-asyncssh/python-asyncssh.changes 2023-07-03 17:42:15.224530767 +0200 +++ /work/SRC/openSUSE:Factory/.python-asyncssh.new.28202/python-asyncssh.changes 2023-10-05 20:06:09.181215623 +0200 @@ -1,0 +2,25 @@ +Thu Oct 5 09:42:35 UTC 2023 - Dirk Müller <dmuel...@suse.com> + +- update to 2.14.0: + * Added support for a new accept_handler argument when setting + up local port forwarding, allowing the client host and port to + be validated and/or logged for each new forwarded connection. + * Added an option to disable expensive RSA private key checks + when using OpenSSL 3.x. Functions that read private keys have + been modified to include a new unsafe_skip_rsa_key_validation + argument which can be used to avoid these additional checks, + if you are loading keys from a trusted source. + * Added host information into AsyncSSH exceptions when host key + validation fails, and a few other improvements related to + X.509 certificate validation errors. + * Fixed a regression which prevented keys loaded into an SSH + agent with a certificate from working correctly beginning in + AsyncSSH after version 2.5.0. + * Fixed an issue which was triggering an internal exception + when shutting down server sessions with the line editor enabled + which could cause some output to be lost on exit, especially when + running on Windows. + * Fixed a documentation error in SSHClientConnectionOptions and + SSHServerConnectionOptions. + +------------------------------------------------------------------- Old: ---- asyncssh-2.13.2.tar.gz New: ---- asyncssh-2.14.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-asyncssh.spec ++++++ --- /var/tmp/diff_new_pack.2czv4J/_old 2023-10-05 20:06:10.345257676 +0200 +++ /var/tmp/diff_new_pack.2czv4J/_new 2023-10-05 20:06:10.349257820 +0200 @@ -19,7 +19,7 @@ %define skip_python2 1 %define skip_python36 1 Name: python-asyncssh -Version: 2.13.2 +Version: 2.14.0 Release: 0 Summary: Asynchronous SSHv2 client and server library License: EPL-2.0 OR GPL-2.0-or-later ++++++ asyncssh-2.13.2.tar.gz -> asyncssh-2.14.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/.github/workflows/run_tests.yml new/asyncssh-2.14.0/.github/workflows/run_tests.yml --- old/asyncssh-2.13.2/.github/workflows/run_tests.yml 2022-12-27 22:30:36.000000000 +0100 +++ new/asyncssh-2.14.0/.github/workflows/run_tests.yml 2023-10-01 02:35:42.000000000 +0200 @@ -111,7 +111,7 @@ V = sys.version_info p = platform.system().lower() subprocess.run( - ['tox', '-e', f'py{V.major}{V.minor}-{p}', '--', '-ra'], + ['tox', 'run', '-e', f'py{V.major}{V.minor}-{p}', '--', '-ra'], check=True) - name: Upload coverage data diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/PKG-INFO new/asyncssh-2.14.0/PKG-INFO --- old/asyncssh-2.13.2/PKG-INFO 2023-06-22 05:08:52.759388400 +0200 +++ new/asyncssh-2.14.0/PKG-INFO 2023-10-01 03:06:55.014456500 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: asyncssh -Version: 2.13.2 +Version: 2.14.0 Summary: AsyncSSH: Asynchronous SSHv2 client and server library Home-page: http://asyncssh.timeheart.net Author: Ron Frederick diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/__init__.py new/asyncssh-2.14.0/asyncssh/__init__.py --- old/asyncssh-2.13.2/asyncssh/__init__.py 2022-08-11 02:01:29.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/__init__.py 2023-10-01 02:35:42.000000000 +0200 @@ -44,6 +44,7 @@ from .connection import SSHAcceptor, SSHClientConnection, SSHServerConnection from .connection import SSHClientConnectionOptions, SSHServerConnectionOptions +from .connection import SSHAcceptHandler from .connection import create_connection, create_server, connect, listen from .connection import connect_reverse, listen_reverse, get_server_host_key from .connection import get_server_auth_methods, run_client, run_server @@ -86,6 +87,8 @@ from .public_key import load_keypairs, load_public_keys, load_certificates from .public_key import load_resident_keys +from .rsa import set_default_skip_rsa_key_validation + from .scp import scp from .session import DataType, SSHClientSession, SSHServerSession @@ -164,5 +167,5 @@ 'read_certificate_list', 'read_known_hosts', 'read_private_key', 'read_private_key_list', 'read_public_key', 'read_public_key_list', 'run_client', 'run_server', 'scp', 'set_debug_level', 'set_log_level', - 'set_sftp_log_level', + 'set_sftp_log_level', 'set_default_skip_rsa_key_validation', ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/agent.py new/asyncssh-2.14.0/asyncssh/agent.py --- old/asyncssh-2.13.2/asyncssh/agent.py 2023-02-18 23:52:45.000000000 +0100 +++ new/asyncssh-2.14.0/asyncssh/agent.py 2023-10-01 02:35:42.000000000 +0200 @@ -149,6 +149,18 @@ self._is_cert = is_cert self._flags = 0 + @property + def has_cert(self) -> bool: + """ Return if this key pair has an associated cert""" + + return self._is_cert + + @property + def has_x509_chain(self) -> bool: + """ Return if this key pair has an associated X.509 cert chain""" + + return False + def set_certificate(self, cert: SSHCertificate) -> None: """Set certificate to use with this key""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/connection.py new/asyncssh-2.14.0/asyncssh/connection.py --- old/asyncssh-2.13.2/asyncssh/connection.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/connection.py 2023-10-01 02:35:42.000000000 +0200 @@ -233,6 +233,7 @@ _VersionArg = DefTuple[BytesOrStr] +SSHAcceptHandler = Callable[[str, int], MaybeAwait[bool]] # SSH service names _USERAUTH_SERVICE = b'ssh-userauth' @@ -2886,10 +2887,10 @@ return SSHForwarder(cast(SSHForwarder, peer)) @async_context_manager - async def forward_local_port(self, listen_host: str, - listen_port: int, - dest_host: str, - dest_port: int) -> SSHListener: + async def forward_local_port( + self, listen_host: str, listen_port: int, + dest_host: str, dest_port: int, + accept_handler: Optional[SSHAcceptHandler] = None) -> SSHListener: """Set up local port forwarding This method is a coroutine which attempts to set up port @@ -2906,10 +2907,17 @@ The hostname or address to forward the connections to :param dest_port: The port number to forward the connections to + :param accept_handler: + A `callable` or coroutine which takes arguments of the + original host and port of the client and decides whether + or not to allow connection forwarding, returning `True` to + accept the connection and begin forwarding or `False` to + reject and close it. :type listen_host: `str` :type listen_port: `int` :type dest_host: `str` :type dest_port: `int` + :type accept_handler: `callable` or coroutine :returns: :class:`SSHListener` @@ -2923,6 +2931,21 @@ Tuple[SSHTCPChannel[bytes], SSHTCPSession[bytes]]: """Forward a local connection over SSH""" + if accept_handler: + result = accept_handler(orig_host, orig_port) + + if inspect.isawaitable(result): + result = await cast(Awaitable[bool], result) + + if not result: + self.logger.info('Request for TCP forwarding from ' + '%s to %s denied by application', + (orig_host, orig_port), + (dest_host, dest_port)) + + raise ChannelOpenError(OPEN_ADMINISTRATIVELY_PROHIBITED, + 'Connection forwarding denied') + return (await self.create_connection(session_factory, dest_host, dest_port, orig_host, orig_port)) @@ -3240,7 +3263,12 @@ self._host_key_alias or self._host, self._peer_addr, self._port, key_data) except ValueError as exc: - raise HostKeyNotVerifiable(str(exc)) from None + host = self._host + + if self._host_key_alias: + host += f' with alias {self._host_key_alias}' + + raise HostKeyNotVerifiable(f'{exc} for host {host}') from None self._server_host_key = host_key return host_key @@ -3288,7 +3316,9 @@ self.logger.info('Auth failed for user %s', self._username) - self._force_close(PermissionDenied('Permission denied')) + self._force_close(PermissionDenied('Permission denied for user ' + f'{self._username} on host ' + f'{self._host}')) def gss_kex_auth_requested(self) -> bool: """Return whether to allow GSS key exchange authentication or not""" @@ -4688,9 +4718,9 @@ **kwargs) # type: ignore @async_context_manager - async def forward_local_port_to_path(self, listen_host: str, - listen_port: int, - dest_path: str) -> SSHListener: + async def forward_local_port_to_path( + self, listen_host: str, listen_port: int, dest_path: str, + accept_handler: Optional[SSHAcceptHandler] = None) -> SSHListener: """Set up local TCP port forwarding to a remote UNIX domain socket This method is a coroutine which attempts to set up port @@ -4705,9 +4735,16 @@ The port number on the local host to listen on :param dest_path: The path on the remote host to forward the connections to + :param accept_handler: + A `callable` or coroutine which takes arguments of the + original host and port of the client and decides whether + or not to allow connection forwarding, returning `True` to + accept the connection and begin forwarding or `False` to + reject and close it. :type listen_host: `str` :type listen_port: `int` :type dest_path: `str` + :type accept_handler: `callable` or coroutine :returns: :class:`SSHListener` @@ -4717,10 +4754,24 @@ async def tunnel_connection( session_factory: SSHUNIXSessionFactory[bytes], - _orig_host: str, _orig_port: int) -> \ + orig_host: str, orig_port: int) -> \ Tuple[SSHUNIXChannel[bytes], SSHUNIXSession[bytes]]: """Forward a local connection over SSH""" + if accept_handler: + result = accept_handler(orig_host, orig_port) + + if inspect.isawaitable(result): + result = await cast(Awaitable[bool], result) + + if not result: + self.logger.info('Request for TCP forwarding from ' + '%s to %s denied by application', + (orig_host, orig_port), dest_path) + + raise ChannelOpenError(OPEN_ADMINISTRATIVELY_PROHIBITED, + 'Connection forwarding denied') + return (await self.create_unix_connection(session_factory, dest_path)) @@ -5730,6 +5781,10 @@ if listener is True: listener = await self.forward_local_port( listen_host, listen_port, listen_host, listen_port) + elif callable(listener): + listener = await self.forward_local_port( + listen_host, listen_port, + listen_host, listen_port, listener) except OSError: self.logger.debug1('Failed to create TCP listener') self._report_global_response(False) @@ -6548,7 +6603,8 @@ if x509_trusted_cert_paths: for path in x509_trusted_cert_paths: if not Path(path).is_dir(): - raise ValueError('Path not a directory: ' + str(path)) + raise ValueError('X.509 trusted certificate path not ' + f'a directory: {path}') self.x509_trusted_certs = x509_trusted_certs self.x509_trusted_cert_paths = x509_trusted_cert_paths @@ -6972,7 +7028,7 @@ build up a configuration. When an option is not explicitly specified, its value will be pulled from this options object (if present) before falling back to the default value. - :type client_factory: `callable` returning :class:`SSHClientConnection` + :type client_factory: `callable` returning :class:`SSHClient` :type proxy_command: `str` or `list` of `str` :type known_hosts: *see* :ref:`SpecifyingKnownHosts` :type host_key_alias: `str` @@ -7621,7 +7677,7 @@ build up a configuration. When an option is not explicitly specified, its value will be pulled from this options object (if present) before falling back to the default value. - :type server_factory: `callable` returning :class:`SSHServerConnection` + :type server_factory: `callable` returning :class:`SSHServer` :type proxy_command: `str` or `list` of `str` :type family: `socket.AF_UNSPEC`, `socket.AF_INET`, or `socket.AF_INET6` :type server_host_keys: *see* :ref:`SpecifyingPrivateKeys` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/crypto/rsa.py new/asyncssh-2.14.0/asyncssh/crypto/rsa.py --- old/asyncssh-2.13.2/asyncssh/crypto/rsa.py 2023-02-18 23:52:45.000000000 +0100 +++ new/asyncssh-2.14.0/asyncssh/crypto/rsa.py 2023-10-01 02:35:42.000000000 +0200 @@ -98,12 +98,14 @@ @classmethod def construct(cls, n: int, e: int, d: int, p: int, q: int, - dmp1: int, dmq1: int, iqmp: int) -> 'RSAPrivateKey': + dmp1: int, dmq1: int, iqmp: int, + skip_validation: bool) -> 'RSAPrivateKey': """Construct an RSA private key""" pub = rsa.RSAPublicNumbers(e, n) priv = rsa.RSAPrivateNumbers(p, q, d, dmp1, dmq1, iqmp, pub) - priv_key = priv.private_key() + priv_key = priv.private_key( + unsafe_skip_rsa_key_validation=skip_validation) return cls(priv_key, pub, priv) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/crypto/x509.py new/asyncssh-2.14.0/asyncssh/crypto/x509.py --- old/asyncssh-2.13.2/asyncssh/crypto/x509.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/crypto/x509.py 2023-10-01 02:35:42.000000000 +0200 @@ -308,7 +308,7 @@ None) x509_ctx.verify_certificate() except crypto.X509StoreContextError as exc: - raise ValueError(str(exc)) from None + raise ValueError(f'X.509 chain validation error: {exc}') from None def generate_x509_certificate(signing_key: PyCAKey, key: PyCAKey, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/editor.py new/asyncssh-2.14.0/asyncssh/editor.py --- old/asyncssh-2.13.2/asyncssh/editor.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/editor.py 2023-10-01 02:35:42.000000000 +0200 @@ -704,8 +704,10 @@ self._ring_bell() self._bell_rung = False - self._chan.write(''.join(self._outbuf)) - self._outbuf.clear() + + if self._outbuf: + self._chan.write(''.join(self._outbuf)) + self._outbuf.clear() else: self._session.data_received(data, datatype) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/public_key.py new/asyncssh-2.14.0/asyncssh/public_key.py --- old/asyncssh-2.13.2/asyncssh/public_key.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/public_key.py 2023-10-01 02:35:42.000000000 +0200 @@ -262,13 +262,13 @@ @classmethod def make_private(cls, key_params: object) -> 'SSHKey': - """Construct an RSA private key""" + """Construct a private key""" raise NotImplementedError @classmethod def make_public(cls, key_params: object) -> 'SSHKey': - """Construct an RSA public key""" + """Construct a public key""" raise NotImplementedError @@ -2096,6 +2096,18 @@ return self._key_type + @property + def has_cert(self) -> bool: + """ Return if this key pair has an associated cert""" + + return bool(self._cert) + + @property + def has_x509_chain(self) -> bool: + """ Return if this key pair has an associated X.509 cert chain""" + + return self._cert.is_x509_chain if self._cert else False + def get_algorithm(self) -> str: """Return the algorithm associated with this key pair""" @@ -2194,9 +2206,9 @@ self.sig_algorithm = sig_algorithm - if not self._cert: + if not self.has_cert: self.algorithm = sig_algorithm - elif self._cert.is_x509_chain: + elif self.has_x509_chain: self.algorithm = sig_algorithm cert = cast('SSHX509CertificateChain', self._cert) @@ -2402,7 +2414,9 @@ return None, (), len(data) -def _decode_pkcs1_private(pem_name: bytes, key_data: object) -> SSHKey: +def _decode_pkcs1_private( + pem_name: bytes, key_data: object, + unsafe_skip_rsa_key_validation: Optional[bool]) -> SSHKey: """Decode a PKCS#1 format private key""" handler = _pem_map.get(pem_name) @@ -2415,6 +2429,10 @@ raise KeyImportError('Invalid %s private key' % pem_name.decode('ascii')) + if pem_name == b'RSA': + key_params = cast(Tuple, key_params) + \ + (unsafe_skip_rsa_key_validation,) + return handler.make_private(key_params) @@ -2434,7 +2452,9 @@ return handler.make_public(key_params) -def _decode_pkcs8_private(key_data: object) -> SSHKey: +def _decode_pkcs8_private( + key_data: object, + unsafe_skip_rsa_key_validation: Optional[bool]) -> SSHKey: """Decode a PKCS#8 format private key""" if (isinstance(key_data, tuple) and len(key_data) >= 3 and @@ -2455,6 +2475,10 @@ handler.pem_name.decode('ascii') if handler.pem_name else 'PKCS#8') + if alg == ObjectIdentifier('1.2.840.113549.1.1.1'): + key_params = cast(Tuple, key_params) + \ + (unsafe_skip_rsa_key_validation,) + return handler.make_private(key_params) else: raise KeyImportError('Invalid PKCS#8 private key') @@ -2486,8 +2510,9 @@ raise KeyImportError('Invalid PKCS#8 public key') -def _decode_openssh_private(data: bytes, - passphrase: Optional[BytesOrStr]) -> SSHKey: +def _decode_openssh_private( + data: bytes, passphrase: Optional[BytesOrStr], + unsafe_skip_rsa_key_validation: Optional[bool]) -> SSHKey: """Decode an OpenSSH format private key""" try: @@ -2578,6 +2603,10 @@ if len(pad) >= block_size or pad != bytes(range(1, len(pad) + 1)): raise KeyImportError('Invalid OpenSSH private key') + if alg == b'ssh-rsa': + key_params = cast(Tuple, key_params) + \ + (unsafe_skip_rsa_key_validation,) + key = handler.make_private(key_params) key.set_comment(comment) return key @@ -2609,8 +2638,9 @@ raise KeyImportError('Invalid OpenSSH private key') from None -def _decode_der_private(key_data: object, - passphrase: Optional[BytesOrStr]) -> SSHKey: +def _decode_der_private( + key_data: object, passphrase: Optional[BytesOrStr], + unsafe_skip_rsa_key_validation: Optional[bool]) -> SSHKey: """Decode a DER format private key""" # First, if there's a passphrase, try to decrypt PKCS#8 @@ -2623,7 +2653,7 @@ # Then, try to decode PKCS#8 try: - return _decode_pkcs8_private(key_data) + return _decode_pkcs8_private(key_data, unsafe_skip_rsa_key_validation) except KeyImportError: # PKCS#8 failed - try PKCS#1 instead pass @@ -2631,7 +2661,8 @@ # If that fails, try each of the possible PKCS#1 encodings for pem_name in _pem_map: try: - return _decode_pkcs1_private(pem_name, key_data) + return _decode_pkcs1_private(pem_name, key_data, + unsafe_skip_rsa_key_validation) except KeyImportError: # Try the next PKCS#1 encoding pass @@ -2667,13 +2698,15 @@ return SSHX509Certificate.construct_from_der(data, comment) -def _decode_pem_private(pem_name: bytes, headers: Mapping[bytes, bytes], - data: bytes, passphrase: Optional[BytesOrStr]) -> \ - SSHKey: +def _decode_pem_private( + pem_name: bytes, headers: Mapping[bytes, bytes], + data: bytes, passphrase: Optional[BytesOrStr], + unsafe_skip_rsa_key_validation: Optional[bool]) -> SSHKey: """Decode a PEM format private key""" if pem_name == b'OPENSSH': - return _decode_openssh_private(data, passphrase) + return _decode_openssh_private(data, passphrase, + unsafe_skip_rsa_key_validation) if headers.get(b'Proc-Type') == b'4,ENCRYPTED': if passphrase is None: @@ -2715,9 +2748,10 @@ 'private key') from None if pem_name: - return _decode_pkcs1_private(pem_name, key_data) + return _decode_pkcs1_private(pem_name, key_data, + unsafe_skip_rsa_key_validation) else: - return _decode_pkcs8_private(key_data) + return _decode_pkcs8_private(key_data, unsafe_skip_rsa_key_validation) def _decode_pem_public(pem_name: bytes, data: bytes) -> SSHKey: @@ -2750,8 +2784,10 @@ return SSHX509Certificate.construct_from_der(data) -def _decode_private(data: bytes, passphrase: Optional[BytesOrStr]) -> \ - Tuple[Optional[SSHKey], Optional[int]]: +def _decode_private( + data: bytes, passphrase: Optional[BytesOrStr], + unsafe_skip_rsa_key_validation: Optional[bool]) -> \ + Tuple[Optional[SSHKey], Optional[int]]: """Decode a private key""" fmt, key_info, end = _match_next(data, b'PRIVATE KEY') @@ -2759,10 +2795,12 @@ key: Optional[SSHKey] if fmt == 'der': - key = _decode_der_private(key_info[0], passphrase) + key = _decode_der_private(key_info[0], passphrase, + unsafe_skip_rsa_key_validation) elif fmt == 'pem': pem_name, headers, data = key_info - key = _decode_pem_private(pem_name, headers, data, passphrase) + key = _decode_pem_private(pem_name, headers, data, passphrase, + unsafe_skip_rsa_key_validation) else: key = None @@ -2799,7 +2837,7 @@ if fmt == 'pem' and key_info[0] == b'OPENSSH': key = _decode_openssh_public(key_info[2]) else: - key, _ = _decode_private(data, None) + key, _ = _decode_private(data, None, False) if key: key = key.convert_to_public() @@ -2836,14 +2874,16 @@ return cert, end -def _decode_private_list(data: bytes, passphrase: Optional[BytesOrStr]) -> \ - Sequence[SSHKey]: +def _decode_private_list( + data: bytes, passphrase: Optional[BytesOrStr], + unsafe_skip_rsa_key_validation: Optional[bool]) -> Sequence[SSHKey]: """Decode a private key list""" keys: List[SSHKey] = [] while data: - key, end = _decode_private(data, passphrase) + key, end = _decode_private(data, passphrase, + unsafe_skip_rsa_key_validation) if key: keys.append(key) @@ -3122,8 +3162,9 @@ key.set_comment(comment) return key -def import_private_key(data: BytesOrStr, - passphrase: Optional[BytesOrStr] = None) -> SSHKey: +def import_private_key( + data: BytesOrStr, passphrase: Optional[BytesOrStr] = None, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> SSHKey: """Import a private key This function imports a private key encoded in PKCS#1 or PKCS#8 DER @@ -3134,8 +3175,13 @@ The data to import. :param passphrase: (optional) The passphrase to use to decrypt the key. + :param unsafe_skip_rsa_key_validation: (optional) + Whether or not to skip key validation when loading RSA private + keys, defaulting to performing these checks unless changed by + calling :func:`set_default_skip_rsa_key_validation`. :type data: `bytes` or ASCII `str` :type passphrase: `str` or `bytes` + :type unsafe_skip_rsa_key_validation: bool :returns: An :class:`SSHKey` private key @@ -3147,7 +3193,7 @@ except UnicodeEncodeError: raise KeyImportError('Invalid encoding for key') from None - key, _ = _decode_private(data, passphrase) + key, _ = _decode_private(data, passphrase, unsafe_skip_rsa_key_validation) if key: return key @@ -3155,12 +3201,14 @@ raise KeyImportError('Invalid private key') -def import_private_key_and_certs(data: bytes, - passphrase: Optional[BytesOrStr] = None) -> \ - Tuple[SSHKey, Optional[SSHX509CertificateChain]]: +def import_private_key_and_certs( + data: bytes, passphrase: Optional[BytesOrStr] = None, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> \ + Tuple[SSHKey, Optional[SSHX509CertificateChain]]: """Import a private key and optional certificate chain""" - key, end = _decode_private(data, passphrase) + key, end = _decode_private(data, passphrase, + unsafe_skip_rsa_key_validation) if key: return key, import_certificate_chain(data[end:]) @@ -3256,8 +3304,9 @@ raise KeyImportError('Invalid certificate subject') -def read_private_key(filename: FilePath, - passphrase: Optional[BytesOrStr] = None) -> SSHKey: +def read_private_key( + filename: FilePath, passphrase: Optional[BytesOrStr] = None, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> SSHKey: """Read a private key from a file This function reads a private key from a file. See the function @@ -3268,26 +3317,34 @@ The file to read the key from. :param passphrase: (optional) The passphrase to use to decrypt the key. + :param unsafe_skip_rsa_key_validation: (optional) + Whether or not to skip key validation when loading RSA private + keys, defaulting to performing these checks unless changed by + calling :func:`set_default_skip_rsa_key_validation`. :type filename: :class:`PurePath <pathlib.PurePath>` or `str` :type passphrase: `str` or `bytes` + :type unsafe_skip_rsa_key_validation: bool :returns: An :class:`SSHKey` private key """ - key = import_private_key(read_file(filename), passphrase) + key = import_private_key(read_file(filename), passphrase, + unsafe_skip_rsa_key_validation) key.set_filename(filename) return key -def read_private_key_and_certs(filename: FilePath, - passphrase: Optional[BytesOrStr] = None) -> \ - Tuple[SSHKey, Optional[SSHX509CertificateChain]]: +def read_private_key_and_certs( + filename: FilePath, passphrase: Optional[BytesOrStr] = None, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> \ + Tuple[SSHKey, Optional[SSHX509CertificateChain]]: """Read a private key and optional certificate chain from a file""" - key, cert = import_private_key_and_certs(read_file(filename), passphrase) + key, cert = import_private_key_and_certs(read_file(filename), passphrase, + unsafe_skip_rsa_key_validation) key.set_filename(filename) @@ -3334,9 +3391,10 @@ return import_certificate(read_file(filename)) -def read_private_key_list(filename: FilePath, - passphrase: Optional[BytesOrStr] = None) -> \ - Sequence[SSHKey]: +def read_private_key_list( + filename: FilePath, passphrase: Optional[BytesOrStr] = None, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> \ + Sequence[SSHKey]: """Read a list of private keys from a file This function reads a list of private keys from a file. See the @@ -3348,14 +3406,20 @@ The file to read the keys from. :param passphrase: (optional) The passphrase to use to decrypt the keys. + :param unsafe_skip_rsa_key_validation: (optional) + Whether or not to skip key validation when loading RSA private + keys, defaulting to performing these checks unless changed by + calling :func:`set_default_skip_rsa_key_validation`. :type filename: :class:`PurePath <pathlib.PurePath>` or `str` :type passphrase: `str` or `bytes` + :type unsafe_skip_rsa_key_validation: bool :returns: A list of :class:`SSHKey` private keys """ - keys = _decode_private_list(read_file(filename), passphrase) + keys = _decode_private_list(read_file(filename), passphrase, + unsafe_skip_rsa_key_validation) for key in keys: key.set_filename(filename) @@ -3404,10 +3468,12 @@ return _decode_certificate_list(read_file(filename)) -def load_keypairs(keylist: KeyPairListArg, - passphrase: Optional[BytesOrStr] = None, - certlist: CertListArg = (), skip_public: bool = False, - ignore_encrypted: bool = False) -> Sequence[SSHKeyPair]: +def load_keypairs( + keylist: KeyPairListArg, passphrase: Optional[BytesOrStr] = None, + certlist: CertListArg = (), skip_public: bool = False, + ignore_encrypted: bool = False, + unsafe_skip_rsa_key_validation: Optional[bool] = None) -> \ + Sequence[SSHKeyPair]: """Load SSH private keys and optional matching certificates This function loads a list of SSH keys and optional matching @@ -3427,10 +3493,15 @@ An internal parameter used to skip public keys and certificates when IdentitiesOnly and IdentityFile are used to specify a mixture of private and public keys. + :param unsafe_skip_rsa_key_validation: (optional) + Whether or not to skip key validation when loading RSA private + keys, defaulting to performing these checks unless changed by + calling :func:`set_default_skip_rsa_key_validation`. :type keylist: *see* :ref:`SpecifyingPrivateKeys` :type passphrase: `str` or `bytes` :type certlist: *see* :ref:`SpecifyingCertificates` :type skip_public: `bool` + :type unsafe_skip_rsa_key_validation: bool :returns: A list of :class:`SSHKeyPair` objects @@ -3444,7 +3515,8 @@ if isinstance(keylist, (PurePath, str)): try: - priv_keys = read_private_key_list(keylist, passphrase) + priv_keys = read_private_key_list(keylist, passphrase, + unsafe_skip_rsa_key_validation) keys_to_load = [keylist] if len(priv_keys) <= 1 else priv_keys except KeyImportError: keys_to_load = [keylist] @@ -3472,21 +3544,25 @@ key_prefix = str(key_to_load) if allow_certs: - key, certs_to_load = \ - read_private_key_and_certs(key_to_load, passphrase) + key, certs_to_load = read_private_key_and_certs( + key_to_load, passphrase, + unsafe_skip_rsa_key_validation) if not certs_to_load: certs_to_load = key_prefix + '-cert.pub' else: - key = read_private_key(key_to_load, passphrase) + key = read_private_key(key_to_load, passphrase, + unsafe_skip_rsa_key_validation) pubkey_to_load = key_prefix + '.pub' elif isinstance(key_to_load, bytes): if allow_certs: - key, certs_to_load = \ - import_private_key_and_certs(key_to_load, passphrase) + key, certs_to_load = import_private_key_and_certs( + key_to_load, passphrase, + unsafe_skip_rsa_key_validation) else: - key = import_private_key(key_to_load, passphrase) + key = import_private_key(key_to_load, passphrase, + unsafe_skip_rsa_key_validation) else: key = key_to_load except KeyImportError as exc: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/rsa.py new/asyncssh-2.14.0/asyncssh/rsa.py --- old/asyncssh-2.13.2/asyncssh/rsa.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/rsa.py 2023-10-01 02:35:42.000000000 +0200 @@ -43,9 +43,49 @@ _PrivateKeyArgs = Tuple[int, int, int, int, int, int, int, int] +_PrivateKeyConstructArgs = Tuple[int, int, int, int, int, int, int, int, bool] _PublicKeyArgs = Tuple[int, int] +_default_skip_rsa_key_validation = False + + +def set_default_skip_rsa_key_validation(skip_validation: bool) -> None: + """Set whether to disable RSA key validation in OpenSSL + + OpenSSL 3.x does additional validation when loading RSA keys + as an added security measure. However, the result is that + loading a key can take significantly longer than it did before. + + If all your RSA keys are coming from a trusted source, you can + call this function with a value of `True` to default to skipping + these checks on RSA keys, reducing the cost back down to what it + was in earlier releases. + + This can also be set on a case by case basis by using the new + `unsafe_skip_rsa_key_validation` argument on the functions used + to load keys. This will only affect loading keys of type RSA. + + .. note:: The extra cost only applies to loading existing keys, and + not to generating new keys. Also, in cases where a key is + used repeatedly, it can be loaded once into an `SSHKey` + object and reused without having to pay the cost each time. + So, this call should not be needed in most applications. + + If an application does need this, it is strongly + recommended that the `unsafe_skip_rsa_key_validation` + argument be used rather than using this function to + change the default behavior for all load operations. + + """ + + # pylint: disable=global-statement + + global _default_skip_rsa_key_validation + + _default_skip_rsa_key_validation = skip_validation + + class RSAKey(SSHKey): """Handler for RSA public key encryption""" @@ -94,9 +134,14 @@ def make_private(cls, key_params: object) -> SSHKey: """Construct an RSA private key""" - n, e, d, p, q, dmp1, dmq1, iqmp = cast(_PrivateKeyArgs, key_params) + n, e, d, p, q, dmp1, dmq1, iqmp, unsafe_skip_rsa_key_validation = \ + cast(_PrivateKeyConstructArgs, key_params) + + if unsafe_skip_rsa_key_validation is None: + unsafe_skip_rsa_key_validation = _default_skip_rsa_key_validation - return cls(RSAPrivateKey.construct(n, e, d, p, q, dmp1, dmq1, iqmp)) + return cls(RSAPrivateKey.construct(n, e, d, p, q, dmp1, dmq1, iqmp, + unsafe_skip_rsa_key_validation)) @classmethod def make_public(cls, key_params: object) -> SSHKey: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/server.py new/asyncssh-2.14.0/asyncssh/server.py --- old/asyncssh-2.13.2/asyncssh/server.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/server.py 2023-10-01 02:35:42.000000000 +0200 @@ -31,7 +31,7 @@ if TYPE_CHECKING: # pylint: disable=cyclic-import - from .connection import SSHServerConnection + from .connection import SSHServerConnection, SSHAcceptHandler from .channel import SSHServerChannel, SSHTCPChannel, SSHUNIXChannel from .session import SSHServerSession, SSHTCPSession, SSHUNIXSession @@ -45,7 +45,7 @@ _NewUNIXSession = Union[bool, 'SSHUNIXSession', SSHSocketSessionFactory, Tuple['SSHUNIXChannel', 'SSHUNIXSession'], Tuple['SSHUNIXChannel', SSHSocketSessionFactory]] -_NewListener = Union[bool, SSHListener] +_NewListener = Union[bool, 'SSHAcceptHandler', SSHListener] class SSHServer: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh/version.py new/asyncssh-2.14.0/asyncssh/version.py --- old/asyncssh-2.13.2/asyncssh/version.py 2023-06-22 04:57:55.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh/version.py 2023-10-01 03:04:52.000000000 +0200 @@ -26,4 +26,4 @@ __url__ = 'http://asyncssh.timeheart.net' -__version__ = '2.13.2' +__version__ = '2.14.0' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh.egg-info/PKG-INFO new/asyncssh-2.14.0/asyncssh.egg-info/PKG-INFO --- old/asyncssh-2.13.2/asyncssh.egg-info/PKG-INFO 2023-06-22 05:08:52.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh.egg-info/PKG-INFO 2023-10-01 03:06:54.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: asyncssh -Version: 2.13.2 +Version: 2.14.0 Summary: AsyncSSH: Asynchronous SSHv2 client and server library Home-page: http://asyncssh.timeheart.net Author: Ron Frederick diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/asyncssh.egg-info/requires.txt new/asyncssh-2.14.0/asyncssh.egg-info/requires.txt --- old/asyncssh-2.13.2/asyncssh.egg-info/requires.txt 2023-06-22 05:08:52.000000000 +0200 +++ new/asyncssh-2.14.0/asyncssh.egg-info/requires.txt 2023-10-01 03:06:54.000000000 +0200 @@ -1,4 +1,4 @@ -cryptography>=3.1 +cryptography>=39.0 typing_extensions>=3.6 [bcrypt] @@ -17,7 +17,7 @@ python-pkcs11>=0.7.0 [pyOpenSSL] -pyOpenSSL>=17.0.0 +pyOpenSSL>=23.0.0 [pywin32] pywin32>=227 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/docs/api.rst new/asyncssh-2.14.0/docs/api.rst --- old/asyncssh-2.13.2/docs/api.rst 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/docs/api.rst 2023-10-01 02:35:42.000000000 +0200 @@ -1617,6 +1617,11 @@ .. index:: SSH agent support +set_default_skip_rsa_key_validation +----------------------------------- + +.. autofunction:: set_default_skip_rsa_key_validation + SSH Agent Support ================= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/docs/changes.rst new/asyncssh-2.14.0/docs/changes.rst --- old/asyncssh-2.13.2/docs/changes.rst 2023-06-22 04:59:01.000000000 +0200 +++ new/asyncssh-2.14.0/docs/changes.rst 2023-10-01 03:04:52.000000000 +0200 @@ -3,6 +3,44 @@ Change Log ========== +Release 2.14.0 (30 Sep 2023) +---------------------------- + +* Added support for a new accept_handler argument when setting up + local port forwarding, allowing the client host and port to be + validated and/or logged for each new forwarded connection. An + accept handler can also be returned from the server_requested + function to provide this functionality when acting as a server. + Thanks go to GitHub user zgxkbtl for suggesting this feature. + +* Added an option to disable expensive RSA private key checks when + using OpenSSL 3.x. Functions that read private keys have been + modified to include a new unsafe_skip_rsa_key_validation argument + which can be used to avoid these additional checks, if you are + loading keys from a trusted source. + +* Added host information into AsyncSSH exceptions when host key + validation fails, and a few other improvements related to X.509 + certificate validation errors. Thanks go to Peter Moore for + suggesting this and providing an example. + +* Fixed a regression which prevented keys loaded into an SSH agent + with a certificate from working correctly beginning in AsyncSSH + after version 2.5.0. Thanks go to GitHub user htol for reporting + this issue and suggesting the commit which caused the problem. + +* Fixed an issue which was triggering an internal exception when + shutting down server sessions with the line editor enabled which + could cause some output to be lost on exit, especially when running + on Windows. Thanks go to GitHub user jerrbe for reporting this issue. + +* Fixed an issue in a unit test seen in Python 3.12 beta. Thanks go + to Georg Sauthoff for providing this fix. + +* Fixed a documentation error in SSHClientConnectionOptions and + SSHServerConnectionOptions. Thanks go to GitHub user bowenerchen + for reporting this issue. + Release 2.13.2 (21 Jun 2023) ---------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/setup.py new/asyncssh-2.14.0/setup.py --- old/asyncssh-2.13.2/setup.py 2022-04-16 22:13:09.000000000 +0200 +++ new/asyncssh-2.14.0/setup.py 2023-10-01 02:35:42.000000000 +0200 @@ -57,14 +57,14 @@ long_description = long_description, platforms = 'Any', python_requires = '>= 3.6', - install_requires = ['cryptography >= 3.1', 'typing_extensions >= 3.6'], + install_requires = ['cryptography >= 39.0', 'typing_extensions >= 3.6'], extras_require = { 'bcrypt': ['bcrypt >= 3.1.3'], 'fido2': ['fido2 >= 0.9.2'], 'gssapi': ['gssapi >= 1.2.0'], 'libnacl': ['libnacl >= 1.4.2'], 'pkcs11': ['python-pkcs11 >= 0.7.0'], - 'pyOpenSSL': ['pyOpenSSL >= 17.0.0'], + 'pyOpenSSL': ['pyOpenSSL >= 23.0.0'], 'pywin32': ['pywin32 >= 227'] }, packages = ['asyncssh', 'asyncssh.crypto'], diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/tests/test_connection.py new/asyncssh-2.14.0/tests/test_connection.py --- old/asyncssh-2.13.2/tests/test_connection.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/tests/test_connection.py 2023-10-01 02:35:42.000000000 +0200 @@ -2068,6 +2068,13 @@ await self.connect() @asynctest + async def test_host_key_unknown(self): + """Test unknown host key alias""" + + with self.assertRaises(asyncssh.HostKeyNotVerifiable): + await self.connect(host_key_alias='unknown') + + @asynctest async def test_host_key_match(self): """Test host key match""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/tests/test_forward.py new/asyncssh-2.14.0/tests/test_forward.py --- old/asyncssh-2.13.2/tests/test_forward.py 2022-12-27 22:30:36.000000000 +0100 +++ new/asyncssh-2.14.0/tests/test_forward.py 2023-10-01 02:35:42.000000000 +0200 @@ -183,6 +183,18 @@ return listen_host != 'fail' +class _TCPAcceptHandlerServer(Server): + """Server for testing forwarding accept handler""" + + async def server_requested(self, listen_host, listen_port): + """Handle a request to create a new socket listener""" + + def accept_handler(_orig_host: str, _orig_port: int) -> bool: + return True + + return accept_handler + + class _UNIXConnectionServer(Server): """Server for testing direct and forwarded UNIX domain connections""" @@ -594,6 +606,39 @@ await self._check_local_connection(listener.get_port(), delay=0.1) + @asynctest + async def test_forward_local_port_accept_handler(self): + """Test forwarding of a local port with an accept handler""" + + def accept_handler(_orig_host: str, _orig_port: int) -> bool: + return True + + async with self.connect() as conn: + async with conn.forward_local_port('', 0, '', 7, + accept_handler) as listener: + await self._check_local_connection(listener.get_port(), + delay=0.1) + + @asynctest + async def test_forward_local_port_accept_handler_denial(self): + """Test forwarding of a local port with an accept handler denial""" + + async def accept_handler(_orig_host: str, _orig_port: int) -> bool: + return False + + async with self.connect() as conn: + async with conn.forward_local_port('', 0, '', 7, + accept_handler) as listener: + listen_port = listener.get_port() + + reader, writer = await asyncio.open_connection('127.0.0.1', + listen_port) + + self.assertEqual((await reader.read()), b'') + + writer.close() + await maybe_wait_closed(writer) + @unittest.skipIf(sys.platform == 'win32', 'skip UNIX domain socket tests on Windows') @asynctest @@ -855,6 +900,33 @@ await listener.wait_closed() +class _TestTCPForwardingAcceptHandler(_CheckForwarding): + """Unit tests for TCP forwarding with accept handler""" + + @classmethod + async def start_server(cls): + """Start an SSH server which supports TCP connection forwarding""" + + return await cls.create_server( + _TCPAcceptHandlerServer, authorized_client_keys='authorized_keys') + + @asynctest + async def test_forward_remote_port_accept_handler(self): + """Test forwarding of a remote port with accept handler""" + + server = await asyncio.start_server(echo, None, 0, + family=socket.AF_INET) + server_port = server.sockets[0].getsockname()[1] + + async with self.connect() as conn: + async with conn.forward_remote_port( + '', 0, '127.0.0.1', server_port) as listener: + await self._check_local_connection(listener.get_port()) + + server.close() + await server.wait_closed() + + class _TestAsyncTCPForwarding(_TestTCPForwarding): """Unit tests for AsyncSSH TCP connection forwarding with async return""" @@ -1000,6 +1072,39 @@ os.remove('local') @asynctest + async def test_forward_local_port_to_path_accept_handler(self): + """Test forwarding of port to UNIX path with accept handler""" + + def accept_handler(_orig_host: str, _orig_port: int) -> bool: + return True + + async with self.connect() as conn: + async with conn.forward_local_port_to_path( + '', 0, '/echo', accept_handler) as listener: + await self._check_local_connection(listener.get_port(), + delay=0.1) + + @asynctest + async def test_forward_local_port_to_path_accept_handler_denial(self): + """Test forwarding of port to UNIX path with accept handler denial""" + + async def accept_handler(_orig_host: str, _orig_port: int) -> bool: + return False + + async with self.connect() as conn: + async with conn.forward_local_port_to_path( + '', 0, '/echo', accept_handler) as listener: + listen_port = listener.get_port() + + reader, writer = await asyncio.open_connection('127.0.0.1', + listen_port) + + self.assertEqual((await reader.read()), b'') + + writer.close() + await maybe_wait_closed(writer) + + @asynctest async def test_forward_local_port_to_path(self): """Test forwarding of a local port to a remote UNIX domain socket""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/tests/test_process.py new/asyncssh-2.14.0/tests/test_process.py --- old/asyncssh-2.13.2/tests/test_process.py 2022-12-30 17:45:50.000000000 +0100 +++ new/asyncssh-2.14.0/tests/test_process.py 2023-10-01 02:35:42.000000000 +0200 @@ -902,7 +902,7 @@ proc1.stdin.write(data) proc1.stdin.write_eof() - stdout_data, _ = await proc2.communicate() + stdout_data = await proc2.stdout.read() self.assertEqual(stdout_data, data.encode('ascii')) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/tests/util.py new/asyncssh-2.14.0/tests/util.py --- old/asyncssh-2.13.2/tests/util.py 2023-06-22 04:57:39.000000000 +0200 +++ new/asyncssh-2.14.0/tests/util.py 2023-10-01 02:35:42.000000000 +0200 @@ -32,8 +32,7 @@ from unittest.mock import patch -from cryptography.hazmat.backends.openssl import backend - +from asyncssh import set_default_skip_rsa_key_validation from asyncssh.gss import gss_available from asyncssh.logging import logger from asyncssh.misc import ConnectionLost, SignalReceived @@ -77,15 +76,10 @@ # pylint: enable=no-member -# pylint: disable=protected-access - -# Disable RSA key blinding to speed up unit tests -backend._rsa_skip_check_key = True - -# pylint: enable=protected-access - _test_keys = {} +set_default_skip_rsa_key_validation(True) + def asynctest(coro): """Decorator for async tests, for use with AsyncTestCase""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/asyncssh-2.13.2/tox.ini new/asyncssh-2.14.0/tox.ini --- old/asyncssh-2.13.2/tox.ini 2022-12-27 22:30:36.000000000 +0100 +++ new/asyncssh-2.14.0/tox.ini 2023-10-01 02:35:42.000000000 +0200 @@ -26,6 +26,7 @@ windows: win32 usedevelop = True setenv = + PIP_USE_PEP517 = 1 COVERAGE_FILE = .coverage.{envname} commands = {envpython} -m pytest --cov --cov-report=term-missing:skip-covered {posargs}