mirror of https://github.com/python/cpython
Issue #1589: Add ssl.match_hostname(), to help implement server identity
verification for higher-level protocols.
This commit is contained in:
parent
e75bc2c6f9
commit
59fdd6736b
|
@ -45,11 +45,27 @@ Functions, Constants, and Exceptions
|
||||||
|
|
||||||
.. exception:: SSLError
|
.. exception:: SSLError
|
||||||
|
|
||||||
Raised to signal an error from the underlying SSL implementation. This
|
Raised to signal an error from the underlying SSL implementation
|
||||||
signifies some problem in the higher-level encryption and authentication
|
(currently provided by the OpenSSL library). This signifies some
|
||||||
layer that's superimposed on the underlying network connection. This error
|
problem in the higher-level encryption and authentication layer that's
|
||||||
|
superimposed on the underlying network connection. This error
|
||||||
is a subtype of :exc:`socket.error`, which in turn is a subtype of
|
is a subtype of :exc:`socket.error`, which in turn is a subtype of
|
||||||
:exc:`IOError`.
|
:exc:`IOError`. The error code and message of :exc:`SSLError` instances
|
||||||
|
are provided by the OpenSSL library.
|
||||||
|
|
||||||
|
.. exception:: CertificateError
|
||||||
|
|
||||||
|
Raised to signal an error with a certificate (such as mismatching
|
||||||
|
hostname). Certificate errors detected by OpenSSL, though, raise
|
||||||
|
an :exc:`SSLError`.
|
||||||
|
|
||||||
|
|
||||||
|
Socket creation
|
||||||
|
^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
The following function allows for standalone socket creation. Starting from
|
||||||
|
Python 3.2, it can be more flexible to use :meth:`SSLContext.wrap_socket`
|
||||||
|
instead.
|
||||||
|
|
||||||
.. function:: wrap_socket(sock, keyfile=None, certfile=None, server_side=False, cert_reqs=CERT_NONE, ssl_version={see docs}, ca_certs=None, do_handshake_on_connect=True, suppress_ragged_eofs=True, ciphers=None)
|
.. function:: wrap_socket(sock, keyfile=None, certfile=None, server_side=False, cert_reqs=CERT_NONE, ssl_version={see docs}, ca_certs=None, do_handshake_on_connect=True, suppress_ragged_eofs=True, ciphers=None)
|
||||||
|
|
||||||
|
@ -139,6 +155,9 @@ Functions, Constants, and Exceptions
|
||||||
.. versionchanged:: 3.2
|
.. versionchanged:: 3.2
|
||||||
New optional argument *ciphers*.
|
New optional argument *ciphers*.
|
||||||
|
|
||||||
|
Random generation
|
||||||
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
.. function:: RAND_status()
|
.. function:: RAND_status()
|
||||||
|
|
||||||
Returns True if the SSL pseudo-random number generator has been seeded with
|
Returns True if the SSL pseudo-random number generator has been seeded with
|
||||||
|
@ -164,6 +183,32 @@ Functions, Constants, and Exceptions
|
||||||
string (so you can always use :const:`0.0`). See :rfc:`1750` for more
|
string (so you can always use :const:`0.0`). See :rfc:`1750` for more
|
||||||
information on sources of entropy.
|
information on sources of entropy.
|
||||||
|
|
||||||
|
Certificate handling
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. function:: match_hostname(cert, hostname)
|
||||||
|
|
||||||
|
Verify that *cert* (in decoded format as returned by
|
||||||
|
:meth:`SSLSocket.getpeercert`) matches the given *hostname*. The rules
|
||||||
|
applied are those for checking the identity of HTTPS servers as outlined
|
||||||
|
in :rfc:`2818`, except that IP addresses are not currently supported.
|
||||||
|
In addition to HTTPS, this function should be suitable for checking the
|
||||||
|
identity of servers in various SSL-based protocols such as FTPS, IMAPS,
|
||||||
|
POPS and others.
|
||||||
|
|
||||||
|
:exc:`CertificateError` is raised on failure. On success, the function
|
||||||
|
returns nothing::
|
||||||
|
|
||||||
|
>>> cert = {'subject': ((('commonName', 'example.com'),),)}
|
||||||
|
>>> ssl.match_hostname(cert, "example.com")
|
||||||
|
>>> ssl.match_hostname(cert, "example.org")
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "<stdin>", line 1, in <module>
|
||||||
|
File "/home/py3k/Lib/ssl.py", line 130, in match_hostname
|
||||||
|
ssl.CertificateError: hostname 'example.org' doesn't match 'example.com'
|
||||||
|
|
||||||
|
.. versionadded:: 3.2
|
||||||
|
|
||||||
.. function:: cert_time_to_seconds(timestring)
|
.. function:: cert_time_to_seconds(timestring)
|
||||||
|
|
||||||
Returns a floating-point value containing a normal seconds-after-the-epoch
|
Returns a floating-point value containing a normal seconds-after-the-epoch
|
||||||
|
@ -178,7 +223,6 @@ Functions, Constants, and Exceptions
|
||||||
>>> import time
|
>>> import time
|
||||||
>>> time.ctime(ssl.cert_time_to_seconds("May 9 00:00:00 2007 GMT"))
|
>>> time.ctime(ssl.cert_time_to_seconds("May 9 00:00:00 2007 GMT"))
|
||||||
'Wed May 9 00:00:00 2007'
|
'Wed May 9 00:00:00 2007'
|
||||||
>>>
|
|
||||||
|
|
||||||
.. function:: get_server_certificate(addr, ssl_version=PROTOCOL_SSLv3, ca_certs=None)
|
.. function:: get_server_certificate(addr, ssl_version=PROTOCOL_SSLv3, ca_certs=None)
|
||||||
|
|
||||||
|
@ -201,6 +245,9 @@ Functions, Constants, and Exceptions
|
||||||
Given a certificate as an ASCII PEM string, returns a DER-encoded sequence of
|
Given a certificate as an ASCII PEM string, returns a DER-encoded sequence of
|
||||||
bytes for that same certificate.
|
bytes for that same certificate.
|
||||||
|
|
||||||
|
Constants
|
||||||
|
^^^^^^^^^
|
||||||
|
|
||||||
.. data:: CERT_NONE
|
.. data:: CERT_NONE
|
||||||
|
|
||||||
Possible value for :attr:`SSLContext.verify_mode`, or the ``cert_reqs``
|
Possible value for :attr:`SSLContext.verify_mode`, or the ``cert_reqs``
|
||||||
|
@ -683,68 +730,51 @@ should use the following idiom::
|
||||||
Client-side operation
|
Client-side operation
|
||||||
^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
This example connects to an SSL server, prints the server's address and
|
This example connects to an SSL server and prints the server's certificate::
|
||||||
certificate, sends some bytes, and reads part of the response::
|
|
||||||
|
|
||||||
import socket, ssl, pprint
|
import socket, ssl, pprint
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
|
||||||
# require a certificate from the server
|
# require a certificate from the server
|
||||||
ssl_sock = ssl.wrap_socket(s,
|
ssl_sock = ssl.wrap_socket(s,
|
||||||
ca_certs="/etc/ca_certs_file",
|
ca_certs="/etc/ca_certs_file",
|
||||||
cert_reqs=ssl.CERT_REQUIRED)
|
cert_reqs=ssl.CERT_REQUIRED)
|
||||||
|
|
||||||
ssl_sock.connect(('www.verisign.com', 443))
|
ssl_sock.connect(('www.verisign.com', 443))
|
||||||
|
|
||||||
print(repr(ssl_sock.getpeername()))
|
|
||||||
pprint.pprint(ssl_sock.getpeercert())
|
pprint.pprint(ssl_sock.getpeercert())
|
||||||
print(pprint.pformat(ssl_sock.getpeercert()))
|
|
||||||
|
|
||||||
# Set a simple HTTP request -- use http.client in actual code.
|
|
||||||
ssl_sock.sendall(b"GET / HTTP/1.0\r\nHost: www.verisign.com\r\n\r\n")
|
|
||||||
|
|
||||||
# Read a chunk of data. Will not necessarily
|
|
||||||
# read all the data returned by the server.
|
|
||||||
data = ssl_sock.recv()
|
|
||||||
|
|
||||||
# note that closing the SSLSocket will also close the underlying socket
|
# note that closing the SSLSocket will also close the underlying socket
|
||||||
ssl_sock.close()
|
ssl_sock.close()
|
||||||
|
|
||||||
As of September 6, 2007, the certificate printed by this program looked like
|
As of October 6, 2010, the certificate printed by this program looks like
|
||||||
this::
|
this::
|
||||||
|
|
||||||
{'notAfter': 'May 8 23:59:59 2009 GMT',
|
{'notAfter': 'May 25 23:59:59 2012 GMT',
|
||||||
'subject': ((('serialNumber', '2497886'),),
|
'subject': ((('1.3.6.1.4.1.311.60.2.1.3', 'US'),),
|
||||||
(('1.3.6.1.4.1.311.60.2.1.3', 'US'),),
|
(('1.3.6.1.4.1.311.60.2.1.2', 'Delaware'),),
|
||||||
(('1.3.6.1.4.1.311.60.2.1.2', 'Delaware'),),
|
(('businessCategory', 'V1.0, Clause 5.(b)'),),
|
||||||
(('countryName', 'US'),),
|
(('serialNumber', '2497886'),),
|
||||||
(('postalCode', '94043'),),
|
(('countryName', 'US'),),
|
||||||
(('stateOrProvinceName', 'California'),),
|
(('postalCode', '94043'),),
|
||||||
(('localityName', 'Mountain View'),),
|
(('stateOrProvinceName', 'California'),),
|
||||||
(('streetAddress', '487 East Middlefield Road'),),
|
(('localityName', 'Mountain View'),),
|
||||||
(('organizationName', 'VeriSign, Inc.'),),
|
(('streetAddress', '487 East Middlefield Road'),),
|
||||||
(('organizationalUnitName',
|
(('organizationName', 'VeriSign, Inc.'),),
|
||||||
'Production Security Services'),),
|
(('organizationalUnitName', ' Production Security Services'),),
|
||||||
(('organizationalUnitName',
|
(('commonName', 'www.verisign.com'),))}
|
||||||
'Terms of use at www.verisign.com/rpa (c)06'),),
|
|
||||||
(('commonName', 'www.verisign.com'),))}
|
|
||||||
|
|
||||||
which is a fairly poorly-formed ``subject`` field.
|
|
||||||
|
|
||||||
This other example first creates an SSL context, instructs it to verify
|
This other example first creates an SSL context, instructs it to verify
|
||||||
certificates sent by peers, and feeds it a set of recognized certificate
|
certificates sent by peers, and feeds it a set of recognized certificate
|
||||||
authorities (CA)::
|
authorities (CA)::
|
||||||
|
|
||||||
>>> context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
|
>>> context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
|
||||||
>>> context.verify_mode = ssl.CERT_OPTIONAL
|
>>> context.verify_mode = ssl.CERT_REQUIRED
|
||||||
>>> context.load_verify_locations("/etc/ssl/certs/ca-bundle.crt")
|
>>> context.load_verify_locations("/etc/ssl/certs/ca-bundle.crt")
|
||||||
|
|
||||||
(it is assumed your operating system places a bundle of all CA certificates
|
(it is assumed your operating system places a bundle of all CA certificates
|
||||||
in ``/etc/ssl/certs/ca-bundle.crt``; if not, you'll get an error and have
|
in ``/etc/ssl/certs/ca-bundle.crt``; if not, you'll get an error and have
|
||||||
to adjust the location)
|
to adjust the location)
|
||||||
|
|
||||||
When you use the context to connect to a server, :const:`CERT_OPTIONAL`
|
When you use the context to connect to a server, :const:`CERT_REQUIRED`
|
||||||
validates the server certificate: it ensures that the server certificate
|
validates the server certificate: it ensures that the server certificate
|
||||||
was signed with one of the CA certificates, and checks the signature for
|
was signed with one of the CA certificates, and checks the signature for
|
||||||
correctness::
|
correctness::
|
||||||
|
@ -752,11 +782,15 @@ correctness::
|
||||||
>>> conn = context.wrap_socket(socket.socket(socket.AF_INET))
|
>>> conn = context.wrap_socket(socket.socket(socket.AF_INET))
|
||||||
>>> conn.connect(("linuxfr.org", 443))
|
>>> conn.connect(("linuxfr.org", 443))
|
||||||
|
|
||||||
You should then fetch the certificate and check its fields for conformity.
|
You should then fetch the certificate and check its fields for conformity::
|
||||||
Here, the ``commonName`` field in the ``subject`` matches the desired HTTPS
|
|
||||||
host ``linuxfr.org``::
|
|
||||||
|
|
||||||
>>> pprint.pprint(conn.getpeercert())
|
>>> cert = conn.getpeercert()
|
||||||
|
>>> ssl.match_hostname(cert, "linuxfr.org")
|
||||||
|
|
||||||
|
Visual inspection shows that the certificate does identify the desired service
|
||||||
|
(that is, the HTTPS host ``linuxfr.org``)::
|
||||||
|
|
||||||
|
>>> pprint.pprint(cert)
|
||||||
{'notAfter': 'Jun 26 21:41:46 2011 GMT',
|
{'notAfter': 'Jun 26 21:41:46 2011 GMT',
|
||||||
'subject': ((('commonName', 'linuxfr.org'),),),
|
'subject': ((('commonName', 'linuxfr.org'),),),
|
||||||
'subjectAltName': (('DNS', 'linuxfr.org'), ('othername', '<unsupported>'))}
|
'subjectAltName': (('DNS', 'linuxfr.org'), ('othername', '<unsupported>'))}
|
||||||
|
@ -776,7 +810,6 @@ the server::
|
||||||
b'',
|
b'',
|
||||||
b'']
|
b'']
|
||||||
|
|
||||||
|
|
||||||
See the discussion of :ref:`ssl-security` below.
|
See the discussion of :ref:`ssl-security` below.
|
||||||
|
|
||||||
|
|
||||||
|
@ -842,12 +875,10 @@ peer, it can be insecure, especially in client mode where most of time you
|
||||||
would like to ensure the authenticity of the server you're talking to.
|
would like to ensure the authenticity of the server you're talking to.
|
||||||
Therefore, when in client mode, it is highly recommended to use
|
Therefore, when in client mode, it is highly recommended to use
|
||||||
:const:`CERT_REQUIRED`. However, it is in itself not sufficient; you also
|
:const:`CERT_REQUIRED`. However, it is in itself not sufficient; you also
|
||||||
have to check that the server certificate (obtained with
|
have to check that the server certificate, which can be obtained by calling
|
||||||
:meth:`SSLSocket.getpeercert`) matches the desired service. The exact way
|
:meth:`SSLSocket.getpeercert`, matches the desired service. For many
|
||||||
of doing so depends on the higher-level protocol used; for example, with
|
protocols and applications, the service can be identified by the hostname;
|
||||||
HTTPS, you'll check that the host name in the URL matches either the
|
in this case, the :func:`match_hostname` function can be used.
|
||||||
``commonName`` field in the ``subjectName``, or one of the ``DNS`` fields
|
|
||||||
in the ``subjectAltName``.
|
|
||||||
|
|
||||||
In server mode, if you want to authenticate your clients using the SSL layer
|
In server mode, if you want to authenticate your clients using the SSL layer
|
||||||
(rather than using a higher-level authentication mechanism), you'll also have
|
(rather than using a higher-level authentication mechanism), you'll also have
|
||||||
|
|
59
Lib/ssl.py
59
Lib/ssl.py
|
@ -55,6 +55,7 @@ PROTOCOL_TLSv1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import re
|
||||||
|
|
||||||
import _ssl # if we can't import it, let the error propagate
|
import _ssl # if we can't import it, let the error propagate
|
||||||
|
|
||||||
|
@ -85,6 +86,64 @@ import traceback
|
||||||
import errno
|
import errno
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _dnsname_to_pat(dn):
|
||||||
|
pats = []
|
||||||
|
for frag in dn.split(r'.'):
|
||||||
|
if frag == '*':
|
||||||
|
# When '*' is a fragment by itself, it matches a non-empty dotless
|
||||||
|
# fragment.
|
||||||
|
pats.append('[^.]+')
|
||||||
|
else:
|
||||||
|
# Otherwise, '*' matches any dotless fragment.
|
||||||
|
frag = re.escape(frag)
|
||||||
|
pats.append(frag.replace(r'\*', '[^.]*'))
|
||||||
|
return re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def match_hostname(cert, hostname):
|
||||||
|
"""Verify that *cert* (in decoded format as returned by
|
||||||
|
SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 rules
|
||||||
|
are mostly followed, but IP addresses are not accepted for *hostname*.
|
||||||
|
|
||||||
|
CertificateError is raised on failure. On success, the function
|
||||||
|
returns nothing.
|
||||||
|
"""
|
||||||
|
if not cert:
|
||||||
|
raise ValueError("empty or no certificate")
|
||||||
|
dnsnames = []
|
||||||
|
san = cert.get('subjectAltName', ())
|
||||||
|
for key, value in san:
|
||||||
|
if key == 'DNS':
|
||||||
|
if _dnsname_to_pat(value).match(hostname):
|
||||||
|
return
|
||||||
|
dnsnames.append(value)
|
||||||
|
if not san:
|
||||||
|
# The subject is only checked when subjectAltName is empty
|
||||||
|
for sub in cert.get('subject', ()):
|
||||||
|
for key, value in sub:
|
||||||
|
# XXX according to RFC 2818, the most specific Common Name
|
||||||
|
# must be used.
|
||||||
|
if key == 'commonName':
|
||||||
|
if _dnsname_to_pat(value).match(hostname):
|
||||||
|
return
|
||||||
|
dnsnames.append(value)
|
||||||
|
if len(dnsnames) > 1:
|
||||||
|
raise CertificateError("hostname %r "
|
||||||
|
"doesn't match either of %s"
|
||||||
|
% (hostname, ', '.join(map(repr, dnsnames))))
|
||||||
|
elif len(dnsnames) == 1:
|
||||||
|
raise CertificateError("hostname %r "
|
||||||
|
"doesn't match %r"
|
||||||
|
% (hostname, dnsnames[0]))
|
||||||
|
else:
|
||||||
|
raise CertificateError("no appropriate commonName or "
|
||||||
|
"subjectAltName fields were found")
|
||||||
|
|
||||||
|
|
||||||
class SSLContext(_SSLContext):
|
class SSLContext(_SSLContext):
|
||||||
"""An SSLContext holds various SSL-related configuration options and
|
"""An SSLContext holds various SSL-related configuration options and
|
||||||
data, such as certificates and possibly a private key."""
|
data, such as certificates and possibly a private key."""
|
||||||
|
|
|
@ -208,6 +208,77 @@ class BasicSocketTests(unittest.TestCase):
|
||||||
ssl.wrap_socket(socket.socket(), certfile=WRONGCERT, keyfile=WRONGCERT)
|
ssl.wrap_socket(socket.socket(), certfile=WRONGCERT, keyfile=WRONGCERT)
|
||||||
self.assertEqual(cm.exception.errno, errno.ENOENT)
|
self.assertEqual(cm.exception.errno, errno.ENOENT)
|
||||||
|
|
||||||
|
def test_match_hostname(self):
|
||||||
|
def ok(cert, hostname):
|
||||||
|
ssl.match_hostname(cert, hostname)
|
||||||
|
def fail(cert, hostname):
|
||||||
|
self.assertRaises(ssl.CertificateError,
|
||||||
|
ssl.match_hostname, cert, hostname)
|
||||||
|
|
||||||
|
cert = {'subject': ((('commonName', 'example.com'),),)}
|
||||||
|
ok(cert, 'example.com')
|
||||||
|
ok(cert, 'ExAmple.cOm')
|
||||||
|
fail(cert, 'www.example.com')
|
||||||
|
fail(cert, '.example.com')
|
||||||
|
fail(cert, 'example.org')
|
||||||
|
fail(cert, 'exampleXcom')
|
||||||
|
|
||||||
|
cert = {'subject': ((('commonName', '*.a.com'),),)}
|
||||||
|
ok(cert, 'foo.a.com')
|
||||||
|
fail(cert, 'bar.foo.a.com')
|
||||||
|
fail(cert, 'a.com')
|
||||||
|
fail(cert, 'Xa.com')
|
||||||
|
fail(cert, '.a.com')
|
||||||
|
|
||||||
|
cert = {'subject': ((('commonName', 'a.*.com'),),)}
|
||||||
|
ok(cert, 'a.foo.com')
|
||||||
|
fail(cert, 'a..com')
|
||||||
|
fail(cert, 'a.com')
|
||||||
|
|
||||||
|
cert = {'subject': ((('commonName', 'f*.com'),),)}
|
||||||
|
ok(cert, 'foo.com')
|
||||||
|
ok(cert, 'f.com')
|
||||||
|
fail(cert, 'bar.com')
|
||||||
|
fail(cert, 'foo.a.com')
|
||||||
|
fail(cert, 'bar.foo.com')
|
||||||
|
|
||||||
|
# Slightly fake real-world example
|
||||||
|
cert = {'notAfter': 'Jun 26 21:41:46 2011 GMT',
|
||||||
|
'subject': ((('commonName', 'linuxfrz.org'),),),
|
||||||
|
'subjectAltName': (('DNS', 'linuxfr.org'),
|
||||||
|
('DNS', 'linuxfr.com'),
|
||||||
|
('othername', '<unsupported>'))}
|
||||||
|
ok(cert, 'linuxfr.org')
|
||||||
|
ok(cert, 'linuxfr.com')
|
||||||
|
# Not a "DNS" entry
|
||||||
|
fail(cert, '<unsupported>')
|
||||||
|
# When there is a subjectAltName, commonName isn't used
|
||||||
|
fail(cert, 'linuxfrz.org')
|
||||||
|
|
||||||
|
# A pristine real-world example
|
||||||
|
cert = {'notAfter': 'Dec 18 23:59:59 2011 GMT',
|
||||||
|
'subject': ((('countryName', 'US'),),
|
||||||
|
(('stateOrProvinceName', 'California'),),
|
||||||
|
(('localityName', 'Mountain View'),),
|
||||||
|
(('organizationName', 'Google Inc'),),
|
||||||
|
(('commonName', 'mail.google.com'),))}
|
||||||
|
ok(cert, 'mail.google.com')
|
||||||
|
fail(cert, 'gmail.com')
|
||||||
|
# Only commonName is considered
|
||||||
|
fail(cert, 'California')
|
||||||
|
|
||||||
|
# Neither commonName nor subjectAltName
|
||||||
|
cert = {'notAfter': 'Dec 18 23:59:59 2011 GMT',
|
||||||
|
'subject': ((('countryName', 'US'),),
|
||||||
|
(('stateOrProvinceName', 'California'),),
|
||||||
|
(('localityName', 'Mountain View'),),
|
||||||
|
(('organizationName', 'Google Inc'),))}
|
||||||
|
fail(cert, 'mail.google.com')
|
||||||
|
|
||||||
|
# Empty cert / no cert
|
||||||
|
self.assertRaises(ValueError, ssl.match_hostname, None, 'example.com')
|
||||||
|
self.assertRaises(ValueError, ssl.match_hostname, {}, 'example.com')
|
||||||
|
|
||||||
|
|
||||||
class ContextTests(unittest.TestCase):
|
class ContextTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,9 @@ Core and Builtins
|
||||||
Library
|
Library
|
||||||
-------
|
-------
|
||||||
|
|
||||||
|
- Issue #1589: Add ssl.match_hostname(), to help implement server identity
|
||||||
|
verification for higher-level protocols.
|
||||||
|
|
||||||
- Issue #9759: GzipFile now raises ValueError when an operation is attempted
|
- Issue #9759: GzipFile now raises ValueError when an operation is attempted
|
||||||
after the file is closed. Patch by Jeffrey Finkelstein.
|
after the file is closed. Patch by Jeffrey Finkelstein.
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue