From 315f312fcec29b05a0ee02b2dba7cb0472f4d3c7 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Thu, 29 Apr 2021 01:26:47 +0200 Subject: [PATCH 1/8] move parsing of points from VerifyingKey to parent class of points For decoding points it's not necessary to have all the data useful for decoding public keys. This will also make it possible to decode explicit EC parameters, as decoding of a public key requires knowledge of the curve's base point and the base point is in defined in the parameters, creating a chicken and an egg problem with using the VerifyingKey.from_string() to parse the base point. --- src/ecdsa/ellipticcurve.py | 254 ++++++++++++++++++++++++++++++++++++- src/ecdsa/errors.py | 6 + src/ecdsa/keys.py | 114 ++--------------- src/ecdsa/test_pyecdsa.py | 6 + 4 files changed, 273 insertions(+), 107 deletions(-) create mode 100644 src/ecdsa/errors.py diff --git a/src/ecdsa/ellipticcurve.py b/src/ecdsa/ellipticcurve.py index e6d8931a..fbe0c6e5 100644 --- a/src/ecdsa/ellipticcurve.py +++ b/src/ecdsa/ellipticcurve.py @@ -49,6 +49,9 @@ from six import python_2_unicode_compatible from . import numbertheory +from ._compat import normalise_bytes +from .errors import MalformedPointError +from .util import orderlen, string_to_number @python_2_unicode_compatible @@ -137,7 +140,161 @@ def __str__(self): ) -class PointJacobi(object): +class AbstractPoint(object): + """Class for common methods of elliptic curve points.""" + @staticmethod + def _from_raw_encoding(data, raw_encoding_length): + """ + Decode public point from :term:`raw encoding`. + + :term:`raw encoding` is the same as the :term:`uncompressed` encoding, + but without the 0x04 byte at the beginning. + """ + # real assert, from_bytes() should not call us with different length + assert len(data) == raw_encoding_length + xs = data[: raw_encoding_length // 2] + ys = data[raw_encoding_length // 2 :] + # real assert, raw_encoding_length is calculated by multiplying an + # integer by two so it will always be even + assert len(xs) == raw_encoding_length // 2 + assert len(ys) == raw_encoding_length // 2 + coord_x = string_to_number(xs) + coord_y = string_to_number(ys) + + return coord_x, coord_y + + @staticmethod + def _from_compressed(data, curve): + """Decode public point from compressed encoding.""" + if data[:1] not in (b"\x02", b"\x03"): + raise MalformedPointError("Malformed compressed point encoding") + + is_even = data[:1] == b"\x02" + x = string_to_number(data[1:]) + p = curve.p() + alpha = (pow(x, 3, p) + (curve.a() * x) + curve.b()) % p + try: + beta = numbertheory.square_root_mod_prime(alpha, p) + except numbertheory.SquareRootError as e: + raise MalformedPointError( + "Encoding does not correspond to a point on curve", e + ) + if is_even == bool(beta & 1): + y = p - beta + else: + y = beta + return x, y + + @classmethod + def _from_hybrid(cls, data, raw_encoding_length, validate_encoding): + """Decode public point from hybrid encoding.""" + # real assert, from_bytes() should not call us with different types + assert data[:1] in (b"\x06", b"\x07") + + # primarily use the uncompressed as it's easiest to handle + x, y = cls._from_raw_encoding(data[1:], raw_encoding_length) + + # but validate if it's self-consistent if we're asked to do that + if validate_encoding and ( + y & 1 + and data[:1] != b"\x07" + or (not y & 1) + and data[:1] != b"\x06" + ): + raise MalformedPointError("Inconsistent hybrid point encoding") + + return x, y + + @classmethod + def from_bytes( + cls, + curve, + data, + validate_encoding=True, + valid_encodings=None + ): + """ + Initialise the object from byte encoding of a point. + + The method does accept and automatically detect the type of point + encoding used. It supports the :term:`raw encoding`, + :term:`uncompressed`, :term:`compressed`, and :term:`hybrid` encodings. + + Note: generally you will want to call the ``from_bytes()`` method of + either a child class, PointJacobi or Point. + + :param data: single point encoding of the public key + :type data: :term:`bytes-like object` + :param curve: the curve on which the public key is expected to lay + :type curve: ecdsa.ellipticcurve.CurveFp + :param validate_encoding: whether to verify that the encoding of the + point is self-consistent, defaults to True, has effect only + on ``hybrid`` encoding + :type validate_encoding: bool + :param valid_encodings: list of acceptable point encoding formats, + supported ones are: :term:`uncompressed`, :term:`compressed`, + :term:`hybrid`, and :term:`raw encoding` (specified with ``raw`` + name). All formats by default (specified with ``None``). + :type valid_encodings: :term:`set-like object` + + :raises MalformedPointError: if the public point does not lay on the + curve or the encoding is invalid + + :return: x and y coordinates of the encoded point + :rtype: tuple(int, int) + """ + if not valid_encodings: + valid_encodings = set( + ["uncompressed", "compressed", "hybrid", "raw"] + ) + if not all( + i in set(("uncompressed", "compressed", "hybrid", "raw")) + for i in valid_encodings + ): + raise ValueError( + "Only uncompressed, compressed, hybrid or raw encoding " + "supported." + ) + data = normalise_bytes(data) + key_len = len(data) + raw_encoding_length = 2 * orderlen(curve.p()) + if key_len == raw_encoding_length and "raw" in valid_encodings: + coord_x, coord_y = cls._from_raw_encoding( + data, raw_encoding_length + ) + elif key_len == raw_encoding_length + 1 and ( + "hybrid" in valid_encodings or "uncompressed" in valid_encodings + ): + if ( + data[:1] in (b"\x06", b"\x07") + and "hybrid" in valid_encodings + ): + coord_x, coord_y = cls._from_hybrid( + data, raw_encoding_length, validate_encoding + ) + elif data[:1] == b"\x04" and "uncompressed" in valid_encodings: + coord_x, coord_y = cls._from_raw_encoding( + data[1:], raw_encoding_length + ) + else: + raise MalformedPointError( + "Invalid X9.62 encoding of the public point" + ) + elif ( + key_len == raw_encoding_length // 2 + 1 + and "compressed" in valid_encodings + ): + coord_x, coord_y = cls._from_compressed(data, curve) + else: + raise MalformedPointError( + "Length of string does not match lengths of " + "any of the enabled ({0}) encodings of the " + "curve.".format(", ".join(valid_encodings)) + ) + return coord_x, coord_y + + +class PointJacobi(AbstractPoint): """ Point on an elliptic curve. Uses Jacobi coordinates. @@ -165,6 +322,7 @@ def __init__(self, curve, x, y, z, order=None, generator=False): such, it will be commonly used with scalar multiplication. This will cause to precompute multiplication table generation for it """ + super(PointJacobi, self).__init__() self.__curve = curve if GMPY: # pragma: no branch self.__coords = (mpz(x), mpz(y), mpz(z)) @@ -175,6 +333,53 @@ def __init__(self, curve, x, y, z, order=None, generator=False): self.__generator = generator self.__precompute = [] + @classmethod + def from_bytes( + cls, + curve, + data, + validate_encoding=True, + valid_encodings=None, + order=None, + generator=False + ): + """ + Initialise the object from byte encoding of a point. + + The method does accept and automatically detect the type of point + encoding used. It supports the :term:`raw encoding`, + :term:`uncompressed`, :term:`compressed`, and :term:`hybrid` encodings. + + :param data: single point encoding of the public key + :type data: :term:`bytes-like object` + :param curve: the curve on which the public key is expected to lay + :type curve: ecdsa.ellipticcurve.CurveFp + :param validate_encoding: whether to verify that the encoding of the + point is self-consistent, defaults to True, has effect only + on ``hybrid`` encoding + :type validate_encoding: bool + :param valid_encodings: list of acceptable point encoding formats, + supported ones are: :term:`uncompressed`, :term:`compressed`, + :term:`hybrid`, and :term:`raw encoding` (specified with ``raw`` + name). All formats by default (specified with ``None``). + :type valid_encodings: :term:`set-like object` + :param int order: the point order, must be non zero when using + generator=True + :param bool generator: the point provided is a curve generator, as + such, it will be commonly used with scalar multiplication. This + will cause to precompute multiplication table generation for it + + :raises MalformedPointError: if the public point does not lay on the + curve or the encoding is invalid + + :return: Point on curve + :rtype: PointJacobi + """ + coord_x, coord_y = super(PointJacobi, cls).from_bytes( + curve, data, validate_encoding, valid_encodings + ) + return PointJacobi(curve, coord_x, coord_y, 1, order, generator) + def _maybe_precompute(self): if not self.__generator or self.__precompute: return @@ -683,12 +888,13 @@ def __neg__(self): return PointJacobi(self.__curve, x, -y, z, self.__order) -class Point(object): +class Point(AbstractPoint): """A point on an elliptic curve. Altering x and y is forbidden, but they can be read by the x() and y() methods.""" def __init__(self, curve, x, y, order=None): """curve, x, y, order; order (optional) is the order of this point.""" + super(Point, self).__init__() self.__curve = curve if GMPY: self.__x = x and mpz(x) @@ -707,6 +913,50 @@ def __init__(self, curve, x, y, order=None): if curve and curve.cofactor() != 1 and order: assert self * order == INFINITY + @classmethod + def from_bytes( + cls, + curve, + data, + validate_encoding=True, + valid_encodings=None, + order=None + ): + """ + Initialise the object from byte encoding of a point. + + The method does accept and automatically detect the type of point + encoding used. It supports the :term:`raw encoding`, + :term:`uncompressed`, :term:`compressed`, and :term:`hybrid` encodings. + + :param data: single point encoding of the public key + :type data: :term:`bytes-like object` + :param curve: the curve on which the public key is expected to lay + :type curve: ecdsa.ellipticcurve.CurveFp + :param validate_encoding: whether to verify that the encoding of the + point is self-consistent, defaults to True, has effect only + on ``hybrid`` encoding + :type validate_encoding: bool + :param valid_encodings: list of acceptable point encoding formats, + supported ones are: :term:`uncompressed`, :term:`compressed`, + :term:`hybrid`, and :term:`raw encoding` (specified with ``raw`` + name). All formats by default (specified with ``None``). + :type valid_encodings: :term:`set-like object` + :param int order: the point order, must be non zero when using + generator=True + + :raises MalformedPointError: if the public point does not lay on the + curve or the encoding is invalid + + :return: Point on curve + :rtype: Point + """ + coord_x, coord_y = super(Point, cls).from_bytes( + curve, data, validate_encoding, valid_encodings + ) + return Point(curve, coord_x, coord_y, order) + + def __eq__(self, other): """Return True if the points are identical, False otherwise. diff --git a/src/ecdsa/errors.py b/src/ecdsa/errors.py new file mode 100644 index 00000000..6edcf475 --- /dev/null +++ b/src/ecdsa/errors.py @@ -0,0 +1,6 @@ +class MalformedPointError(AssertionError): + """Raised in case the encoding of private or public key is malformed.""" + + pass + + diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index e0723540..a8dd3f1f 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -90,6 +90,8 @@ MalformedSignature, ) from ._compat import normalise_bytes +from .errors import MalformedPointError +from .ellipticcurve import PointJacobi __all__ = [ @@ -122,12 +124,6 @@ class BadDigestError(Exception): pass -class MalformedPointError(AssertionError): - """Raised in case the encoding of private or public key is malformed.""" - - pass - - def _truncate_and_convert_digest(digest, curve, allow_truncate): """Truncates and converts digest to an integer.""" if not allow_truncate: @@ -269,71 +265,6 @@ def precompute(self, lazy=False): if not lazy: self.pubkey.point * 2 - @staticmethod - def _from_raw_encoding(string, curve): - """ - Decode public point from :term:`raw encoding`. - - :term:`raw encoding` is the same as the :term:`uncompressed` encoding, - but without the 0x04 byte at the beginning. - """ - order = curve.order - # real assert, from_string() should not call us with different length - assert len(string) == curve.verifying_key_length - xs = string[: curve.verifying_key_length // 2] - ys = string[curve.verifying_key_length // 2 :] - # real assert, verifying_key_length is calculated by multiplying an - # integer by two so it will always be even - assert len(xs) == curve.verifying_key_length // 2 - assert len(ys) == curve.verifying_key_length // 2 - x = string_to_number(xs) - y = string_to_number(ys) - - return ellipticcurve.PointJacobi(curve.curve, x, y, 1, order) - - @staticmethod - def _from_compressed(string, curve): - """Decode public point from compressed encoding.""" - if string[:1] not in (b("\x02"), b("\x03")): - raise MalformedPointError("Malformed compressed point encoding") - - is_even = string[:1] == b("\x02") - x = string_to_number(string[1:]) - order = curve.order - p = curve.curve.p() - alpha = (pow(x, 3, p) + (curve.curve.a() * x) + curve.curve.b()) % p - try: - beta = square_root_mod_prime(alpha, p) - except SquareRootError as e: - raise MalformedPointError( - "Encoding does not correspond to a point on curve", e - ) - if is_even == bool(beta & 1): - y = p - beta - else: - y = beta - return ellipticcurve.PointJacobi(curve.curve, x, y, 1, order) - - @classmethod - def _from_hybrid(cls, string, curve, validate_point): - """Decode public point from hybrid encoding.""" - # real assert, from_string() should not call us with different types - assert string[:1] in (b("\x06"), b("\x07")) - - # primarily use the uncompressed as it's easiest to handle - point = cls._from_raw_encoding(string[1:], curve) - - # but validate if it's self-consistent if we're asked to do that - if validate_point and ( - point.y() & 1 - and string[:1] != b("\x07") - or (not point.y() & 1) - and string[:1] != b("\x06") - ): - raise MalformedPointError("Inconsistent hybrid point encoding") - - return point - @classmethod def from_string( cls, @@ -348,7 +279,7 @@ def from_string( The method does accept and automatically detect the type of point encoding used. It supports the :term:`raw encoding`, - :term:`uncompressed`, :term:`compressed` and :term:`hybrid` encodings. + :term:`uncompressed`, :term:`compressed`, and :term:`hybrid` encodings. Note, while the method is named "from_string" it's a misnomer from Python 2 days when there were no binary strings. In Python 3 the @@ -376,39 +307,12 @@ def from_string( :return: Initialised VerifyingKey object :rtype: VerifyingKey """ - if valid_encodings is None: - valid_encodings = set( - ["uncompressed", "compressed", "hybrid", "raw"] - ) - string = normalise_bytes(string) - sig_len = len(string) - if sig_len == curve.verifying_key_length and "raw" in valid_encodings: - point = cls._from_raw_encoding(string, curve) - elif sig_len == curve.verifying_key_length + 1 and ( - "hybrid" in valid_encodings or "uncompressed" in valid_encodings - ): - if ( - string[:1] in (b("\x06"), b("\x07")) - and "hybrid" in valid_encodings - ): - point = cls._from_hybrid(string, curve, validate_point) - elif string[:1] == b("\x04") and "uncompressed" in valid_encodings: - point = cls._from_raw_encoding(string[1:], curve) - else: - raise MalformedPointError( - "Invalid X9.62 encoding of the public point" - ) - elif ( - sig_len == curve.verifying_key_length // 2 + 1 - and "compressed" in valid_encodings - ): - point = cls._from_compressed(string, curve) - else: - raise MalformedPointError( - "Length of string does not match lengths of " - "any of the enabled ({1}) encodings of {0} " - "curve.".format(curve.name, ", ".join(valid_encodings)) - ) + point = PointJacobi.from_bytes( + curve.curve, + string, + validate_encoding=validate_point, + valid_encodings=valid_encodings + ) return cls.from_public_point(point, curve, hashfunc, validate_point) @classmethod diff --git a/src/ecdsa/test_pyecdsa.py b/src/ecdsa/test_pyecdsa.py index d1418c01..194e5e4c 100644 --- a/src/ecdsa/test_pyecdsa.py +++ b/src/ecdsa/test_pyecdsa.py @@ -745,6 +745,12 @@ def test_raw_decoding_with_blocked_format(self): self.assertIn("hybrid", str(exp.exception)) + def test_decoding_with_unknown_format(self): + with self.assertRaises(ValueError) as e: + VerifyingKey.from_string(b"", valid_encodings=("raw", "foobar")) + + self.assertIn("Only uncompressed, compressed", str(e.exception)) + def test_uncompressed_decoding_with_blocked_format(self): enc = b( "\x04" From 078882e3845539880205677598620f0749288f66 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Thu, 29 Apr 2021 02:00:45 +0200 Subject: [PATCH 2/8] add support for reading and writing curve parameters in DER --- src/ecdsa/curves.py | 144 +++++++++++++++++++++- src/ecdsa/ellipticcurve.py | 71 ++++++++--- src/ecdsa/errors.py | 2 - src/ecdsa/keys.py | 36 +----- src/ecdsa/test_curves.py | 239 +++++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 56 deletions(-) create mode 100644 src/ecdsa/test_curves.py diff --git a/src/ecdsa/curves.py b/src/ecdsa/curves.py index 90cdd52e..d14d2a87 100644 --- a/src/ecdsa/curves.py +++ b/src/ecdsa/curves.py @@ -1,7 +1,8 @@ from __future__ import division -from . import der, ecdsa -from .util import orderlen +from . import der, ecdsa, ellipticcurve +from .util import orderlen, number_to_string, string_to_number +from ._compat import normalise_bytes # orderlen was defined in this module previously, so keep it in __all__, @@ -29,9 +30,15 @@ "BRAINPOOLP320r1", "BRAINPOOLP384r1", "BRAINPOOLP512r1", + "PRIME_FIELD_OID", + "CHARACTERISTIC_TWO_FIELD_OID", ] +PRIME_FIELD_OID = (1, 2, 840, 10045, 1, 1) +CHARACTERISTIC_TWO_FIELD_OID = (1, 2, 840, 10045, 1, 2) + + class UnknownCurveError(Exception): pass @@ -47,11 +54,142 @@ def __init__(self, name, curve, generator, oid, openssl_name=None): self.verifying_key_length = 2 * orderlen(curve.p()) self.signature_length = 2 * self.baselen self.oid = oid - self.encoded_oid = der.encode_oid(*oid) + if oid: + self.encoded_oid = der.encode_oid(*oid) + + def __eq__(self, other): + if isinstance(other, Curve): + return ( + self.curve == other.curve and self.generator == other.generator + ) + return NotImplemented + + def __ne__(self, other): + return not self == other def __repr__(self): return self.name + def to_der(self, encoding=None, point_encoding="uncompressed"): + """Serialise the curve parameters to binary string. + + :param str encoding: the format to save the curve parameters in. + Default is ``named_curve``, with fallback being the ``explicit`` + if the OID is not set for the curve. + :param str point_encoding: the point encoding of the generator when + explicit curve encoding is used. Ignored for ``named_curve`` + format. + """ + if encoding is None: + if self.oid: + encoding = "named_curve" + else: + encoding = "explicit" + + if encoding == "named_curve": + if not self.oid: + raise UnknownCurveError( + "Can't encode curve using named_curve encoding without " + "associated curve OID" + ) + return der.encode_oid(*self.oid) + + # encode the ECParameters sequence + curve_p = self.curve.p() + version = der.encode_integer(1) + field_id = der.encode_sequence( + der.encode_oid(*PRIME_FIELD_OID), der.encode_integer(curve_p) + ) + curve = der.encode_sequence( + der.encode_octet_string( + number_to_string(self.curve.a() % curve_p, curve_p) + ), + der.encode_octet_string( + number_to_string(self.curve.b() % curve_p, curve_p) + ), + ) + base = der.encode_octet_string(self.generator.to_bytes(point_encoding)) + order = der.encode_integer(self.generator.order()) + seq_elements = [version, field_id, curve, base, order] + if self.curve.cofactor(): + cofactor = der.encode_integer(self.curve.cofactor()) + seq_elements.append(cofactor) + + return der.encode_sequence(*seq_elements) + + @staticmethod + def from_der(data): + """Decode the curve parameters from DER file. + + :param data: the binary string to decode the parameters from + :type data: bytes-like object + """ + data = normalise_bytes(data) + if not der.is_sequence(data): + oid, empty = der.remove_object(data) + if empty: + raise der.UnexpectedDER("Unexpected data after OID") + return find_curve(oid) + + seq, empty = der.remove_sequence(data) + if empty: + raise der.UnexpectedDER( + "Unexpected data after ECParameters structure" + ) + # decode the ECParameters sequence + version, rest = der.remove_integer(seq) + if version != 1: + raise der.UnexpectedDER("Unknown parameter encoding format") + field_id, rest = der.remove_sequence(rest) + curve, rest = der.remove_sequence(rest) + base_bytes, rest = der.remove_octet_string(rest) + order, rest = der.remove_integer(rest) + cofactor = None + if rest: + cofactor, rest = der.remove_integer(rest) + + # decode the ECParameters.fieldID sequence + field_type, rest = der.remove_object(field_id) + if field_type == CHARACTERISTIC_TWO_FIELD_OID: + raise UnknownCurveError("Characteristic 2 curves unsupported") + if field_type != PRIME_FIELD_OID: + raise UnknownCurveError( + "Unknown field type: {0}".format(field_type) + ) + prime, empty = der.remove_integer(rest) + if empty: + raise der.UnexpectedDER( + "Unexpected data after ECParameters.fieldID.Prime-p element" + ) + + # decode the ECParameters.curve sequence + curve_a_bytes, rest = der.remove_octet_string(curve) + curve_b_bytes, rest = der.remove_octet_string(rest) + # seed can be defined here, but we don't parse it, so ignore `rest` + + curve_a = string_to_number(curve_a_bytes) + curve_b = string_to_number(curve_b_bytes) + + curve_fp = ellipticcurve.CurveFp(prime, curve_a, curve_b, cofactor) + + # decode the ECParameters.base point + + base = ellipticcurve.PointJacobi.from_bytes( + curve_fp, + base_bytes, + valid_encodings=("uncompressed", "compressed", "hybrid"), + order=order, + generator=True, + ) + tmp_curve = Curve("unknown", curve_fp, base, None) + + # if the curve matches one of the well-known ones, use the well-known + # one in preference, as it will have the OID and name associated + for i in curves: + if tmp_curve == i: + return i + return tmp_curve + # the SEC curves SECP112r1 = Curve( diff --git a/src/ecdsa/ellipticcurve.py b/src/ecdsa/ellipticcurve.py index fbe0c6e5..6d0bc304 100644 --- a/src/ecdsa/ellipticcurve.py +++ b/src/ecdsa/ellipticcurve.py @@ -51,7 +51,7 @@ from . import numbertheory from ._compat import normalise_bytes from .errors import MalformedPointError -from .util import orderlen, string_to_number +from .util import orderlen, string_to_number, number_to_string @python_2_unicode_compatible @@ -101,10 +101,11 @@ def __eq__(self, other): only the prime and curve parameters are considered. """ if isinstance(other, CurveFp): + p = self.__p return ( self.__p == other.__p - and self.__a == other.__a - and self.__b == other.__b + and self.__a % p == other.__a % p + and self.__b % p == other.__b % p ) return NotImplemented @@ -142,6 +143,7 @@ def __str__(self): class AbstractPoint(object): """Class for common methods of elliptic curve points.""" + @staticmethod def _from_raw_encoding(data, raw_encoding_length): """ @@ -207,11 +209,7 @@ def _from_hybrid(cls, data, raw_encoding_length, validate_encoding): @classmethod def from_bytes( - cls, - curve, - data, - validate_encoding=True, - valid_encodings=None + cls, curve, data, validate_encoding=True, valid_encodings=None ): """ Initialise the object from byte encoding of a point. @@ -265,17 +263,14 @@ def from_bytes( elif key_len == raw_encoding_length + 1 and ( "hybrid" in valid_encodings or "uncompressed" in valid_encodings ): - if ( - data[:1] in (b"\x06", b"\x07") - and "hybrid" in valid_encodings - ): + if data[:1] in (b"\x06", b"\x07") and "hybrid" in valid_encodings: coord_x, coord_y = cls._from_hybrid( data, raw_encoding_length, validate_encoding ) elif data[:1] == b"\x04" and "uncompressed" in valid_encodings: - coord_x, coord_y = cls._from_raw_encoding( + coord_x, coord_y = cls._from_raw_encoding( data[1:], raw_encoding_length - ) + ) else: raise MalformedPointError( "Invalid X9.62 encoding of the public point" @@ -293,6 +288,49 @@ def from_bytes( ) return coord_x, coord_y + def _raw_encode(self): + """Convert the point to the :term:`raw encoding`.""" + prime = self.curve().p() + x_str = number_to_string(self.x(), prime) + y_str = number_to_string(self.y(), prime) + return x_str + y_str + + def _compressed_encode(self): + """Encode the point into the compressed form.""" + prime = self.curve().p() + x_str = number_to_string(self.x(), prime) + if self.y() & 1: + return b"\x03" + x_str + return b"\x02" + x_str + + def _hybrid_encode(self): + """Encode the point into the hybrid form.""" + raw_enc = self._raw_encode() + if self.y() & 1: + return b"\x07" + raw_enc + return b"\x06" + raw_enc + + def to_bytes(self, encoding="raw"): + """ + Convert the point to a byte string. + + The method by default uses the :term:`raw encoding` (specified + by `encoding="raw"`. It can also output points in :term:`uncompressed`, + :term:`compressed`, and :term:`hybrid` formats. + + :return: :term:`raw encoding` of a public on the curve + :rtype: bytes + """ + assert encoding in ("raw", "uncompressed", "compressed", "hybrid") + if encoding == "raw": + return self._raw_encode() + elif encoding == "uncompressed": + return b"\x04" + self._raw_encode() + elif encoding == "hybrid": + return self._hybrid_encode() + else: + return self._compressed_encode() + class PointJacobi(AbstractPoint): """ @@ -341,7 +379,7 @@ def from_bytes( validate_encoding=True, valid_encodings=None, order=None, - generator=False + generator=False, ): """ Initialise the object from byte encoding of a point. @@ -920,7 +958,7 @@ def from_bytes( data, validate_encoding=True, valid_encodings=None, - order=None + order=None, ): """ Initialise the object from byte encoding of a point. @@ -956,7 +994,6 @@ def from_bytes( ) return Point(curve, coord_x, coord_y, order) - def __eq__(self, other): """Return True if the points are identical, False otherwise. diff --git a/src/ecdsa/errors.py b/src/ecdsa/errors.py index 6edcf475..0184c05b 100644 --- a/src/ecdsa/errors.py +++ b/src/ecdsa/errors.py @@ -2,5 +2,3 @@ class MalformedPointError(AssertionError): """Raised in case the encoding of private or public key is malformed.""" pass - - diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index a8dd3f1f..6d44cb35 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -78,7 +78,6 @@ from . import rfc6979 from . import ellipticcurve from .curves import NIST192p, find_curve -from .numbertheory import square_root_mod_prime, SquareRootError from .ecdsa import RSZeroError from .util import string_to_number, number_to_string, randrange from .util import sigencode_string, sigdecode_string, bit_length @@ -311,7 +310,7 @@ def from_string( curve.curve, string, validate_encoding=validate_point, - valid_encodings=valid_encodings + valid_encodings=valid_encodings, ) return cls.from_public_point(point, curve, hashfunc, validate_point) @@ -526,30 +525,6 @@ def from_public_key_recovery_with_digest( ] return verifying_keys - def _raw_encode(self): - """Convert the public key to the :term:`raw encoding`.""" - order = self.curve.curve.p() - x_str = number_to_string(self.pubkey.point.x(), order) - y_str = number_to_string(self.pubkey.point.y(), order) - return x_str + y_str - - def _compressed_encode(self): - """Encode the public point into the compressed form.""" - order = self.curve.curve.p() - x_str = number_to_string(self.pubkey.point.x(), order) - if self.pubkey.point.y() & 1: - return b("\x03") + x_str - else: - return b("\x02") + x_str - - def _hybrid_encode(self): - """Encode the public point into the hybrid form.""" - raw_enc = self._raw_encode() - if self.pubkey.point.y() & 1: - return b("\x07") + raw_enc - else: - return b("\x06") + raw_enc - def to_string(self, encoding="raw"): """ Convert the public key to a byte string. @@ -571,14 +546,7 @@ def to_string(self, encoding="raw"): :rtype: bytes """ assert encoding in ("raw", "uncompressed", "compressed", "hybrid") - if encoding == "raw": - return self._raw_encode() - elif encoding == "uncompressed": - return b("\x04") + self._raw_encode() - elif encoding == "hybrid": - return self._hybrid_encode() - else: - return self._compressed_encode() + return self.pubkey.point.to_bytes(encoding) def to_pem(self, point_encoding="uncompressed"): """ diff --git a/src/ecdsa/test_curves.py b/src/ecdsa/test_curves.py new file mode 100644 index 00000000..06a0d742 --- /dev/null +++ b/src/ecdsa/test_curves.py @@ -0,0 +1,239 @@ +try: + import unittest2 as unittest +except ImportError: + import unittest + +import base64 +import pytest +from .curves import Curve, NIST256p, curves, UnknownCurveError, PRIME_FIELD_OID +from .ellipticcurve import CurveFp, PointJacobi +from . import der +from .util import number_to_string + + +class TestParameterEncoding(unittest.TestCase): + @classmethod + def setUpClass(cls): + # minimal, but with cofactor (excludes seed when compared to + # OpenSSL output) + cls.base64_params = ( + "MIHgAgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP/////////" + "//////zBEBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12K" + "o6k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsEQQRrF9Hy4SxCR/i85uVjpEDyd" + "wN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1" + "AiEA/////wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQE=" + ) + + def test_compare_with_different_object(self): + self.assertNotEqual(NIST256p, 256) + + def test_named_curve_params_der(self): + encoded = NIST256p.to_der() + + # just the encoding of the NIST256p OID (prime256v1) + self.assertEqual(b"\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07", encoded) + + def test_verify_that_default_is_named_curve_der(self): + encoded_default = NIST256p.to_der() + encoded_named = NIST256p.to_der("named_curve") + + self.assertEqual(encoded_default, encoded_named) + + def test_encoding_to_explicit_params(self): + encoded = NIST256p.to_der("explicit") + + self.assertEqual(encoded, bytes(base64.b64decode(self.base64_params))) + + def test_encoding_to_explicit_compressed_params(self): + encoded = NIST256p.to_der("explicit", "compressed") + + compressed_base_point = ( + "MIHAAgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////" + "/////zBEBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6" + "k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsEIQNrF9Hy4SxCR/i85uVjpEDydwN9" + "gS3rM6D0oTlF2JjClgIhAP////8AAAAA//////////+85vqtpxeehPO5ysL8YyVR" + "AgEB" + ) + + self.assertEqual( + encoded, bytes(base64.b64decode(compressed_base_point)) + ) + + def test_decoding_explicit_from_openssl(self): + # generated with openssl 1.1.1k using + # openssl ecparam -name P-256 -param_enc explicit -out /tmp/file.pem + p256_explicit = ( + "MIH3AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////" + "/////zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6" + "k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+" + "kARBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tK" + "fA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTz" + "ucrC/GMlUQIBAQ==" + ) + + decoded = Curve.from_der(bytes(base64.b64decode(p256_explicit))) + + self.assertEqual(NIST256p, decoded) + + def test_decoding_well_known_from_explicit_params(self): + curve = Curve.from_der(bytes(base64.b64decode(self.base64_params))) + + self.assertIs(curve, NIST256p) + + def test_compare_curves_with_different_generators(self): + curve_fp = CurveFp(23, 1, 7) + base_a = PointJacobi(curve_fp, 13, 3, 1, 9, generator=True) + base_b = PointJacobi(curve_fp, 1, 20, 1, 9, generator=True) + + curve_a = Curve("unknown", curve_fp, base_a, None) + curve_b = Curve("unknown", curve_fp, base_b, None) + + self.assertNotEqual(curve_a, curve_b) + + def test_default_encode_for_custom_curve(self): + curve_fp = CurveFp(23, 1, 7) + base_point = PointJacobi(curve_fp, 13, 3, 1, 9, generator=True) + + curve = Curve("unknown", curve_fp, base_point, None) + + encoded = curve.to_der() + + decoded = Curve.from_der(encoded) + + self.assertEqual(curve, decoded) + + expected = "MCECAQEwDAYHKoZIzj0BAQIBFzAGBAEBBAEHBAMEDQMCAQk=" + + self.assertEqual(encoded, bytes(base64.b64decode(expected))) + + def test_named_curve_encode_for_custom_curve(self): + curve_fp = CurveFp(23, 1, 7) + base_point = PointJacobi(curve_fp, 13, 3, 1, 9, generator=True) + + curve = Curve("unknown", curve_fp, base_point, None) + + with self.assertRaises(UnknownCurveError) as e: + curve.to_der("named_curve") + + self.assertIn("Can't encode curve", str(e.exception)) + + def test_try_decoding_binary_explicit(self): + sect113r1_explicit = ( + "MIGRAgEBMBwGByqGSM49AQIwEQIBcQYJKoZIzj0BAgMCAgEJMDkEDwAwiCUMpufH" + "/mSc6Fgg9wQPAOi+5NPiJgdEGIvg6ccjAxUAEOcjqxTWluZ2h1YVF1b+v4/LSakE" + "HwQAnXNhbzX0qxQH1zViwQ8ApSgwJ3lY7oTRMV7TGIYCDwEAAAAAAAAA2czsijnl" + "bwIBAg==" + ) + + with self.assertRaises(UnknownCurveError) as e: + Curve.from_der(base64.b64decode(sect113r1_explicit)) + + self.assertIn("Characteristic 2 curves unsupported", str(e.exception)) + + def test_decode_malformed_named_curve(self): + bad_der = der.encode_oid(*NIST256p.oid) + der.encode_integer(1) + + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_der(bad_der) + + self.assertIn("Unexpected data after OID", str(e.exception)) + + def test_decode_malformed_explicit_garbage_after_ECParam(self): + bad_der = bytes( + base64.b64decode(self.base64_params) + ) + der.encode_integer(1) + + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_der(bad_der) + + self.assertIn("Unexpected data after ECParameters", str(e.exception)) + + def test_decode_malformed_unknown_version_number(self): + bad_der = der.encode_sequence(der.encode_integer(2)) + + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_der(bad_der) + + self.assertIn("Unknown parameter encoding format", str(e.exception)) + + def test_decode_malformed_unknown_field_type(self): + curve_p = NIST256p.curve.p() + bad_der = der.encode_sequence( + der.encode_integer(1), + der.encode_sequence( + der.encode_oid(1, 2, 3), der.encode_integer(curve_p) + ), + der.encode_sequence( + der.encode_octet_string( + number_to_string(NIST256p.curve.a() % curve_p, curve_p) + ), + der.encode_octet_string( + number_to_string(NIST256p.curve.b(), curve_p) + ), + ), + der.encode_octet_string( + NIST256p.generator.to_bytes("uncompressed") + ), + der.encode_integer(NIST256p.generator.order()), + ) + + with self.assertRaises(UnknownCurveError) as e: + Curve.from_der(bad_der) + + self.assertIn("Unknown field type: (1, 2, 3)", str(e.exception)) + + def test_decode_malformed_garbage_after_prime(self): + curve_p = NIST256p.curve.p() + bad_der = der.encode_sequence( + der.encode_integer(1), + der.encode_sequence( + der.encode_oid(*PRIME_FIELD_OID), + der.encode_integer(curve_p), + der.encode_integer(1), + ), + der.encode_sequence( + der.encode_octet_string( + number_to_string(NIST256p.curve.a() % curve_p, curve_p) + ), + der.encode_octet_string( + number_to_string(NIST256p.curve.b(), curve_p) + ), + ), + der.encode_octet_string( + NIST256p.generator.to_bytes("uncompressed") + ), + der.encode_integer(NIST256p.generator.order()), + ) + + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_der(bad_der) + + self.assertIn("Prime-p element", str(e.exception)) + + +@pytest.mark.parametrize("curve", curves, ids=[i.name for i in curves]) +def test_curve_params_encode_decode_named(curve): + ret = Curve.from_der(curve.to_der("named_curve")) + + assert curve == ret + + +@pytest.mark.parametrize("curve", curves, ids=[i.name for i in curves]) +def test_curve_params_encode_decode_explicit(curve): + ret = Curve.from_der(curve.to_der("explicit")) + + assert curve == ret + + +@pytest.mark.parametrize("curve", curves, ids=[i.name for i in curves]) +def test_curve_params_encode_decode_default(curve): + ret = Curve.from_der(curve.to_der()) + + assert curve == ret + + +@pytest.mark.parametrize("curve", curves, ids=[i.name for i in curves]) +def test_curve_params_encode_decode_explicit_compressed(curve): + ret = Curve.from_der(curve.to_der("explicit", "compressed")) + + assert curve == ret From 5ddcd9bcc208b7924e7b9688f39a6a9b57eda32b Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Sat, 1 May 2021 20:08:14 +0200 Subject: [PATCH 3/8] support for PEM format for EC parameters --- src/ecdsa/curves.py | 37 +++++++++++++++++++++++++++++++++++++ src/ecdsa/test_curves.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/ecdsa/curves.py b/src/ecdsa/curves.py index d14d2a87..3a8b45da 100644 --- a/src/ecdsa/curves.py +++ b/src/ecdsa/curves.py @@ -1,5 +1,6 @@ from __future__ import division +from six import PY2 from . import der, ecdsa, ellipticcurve from .util import orderlen, number_to_string, string_to_number from ._compat import normalise_bytes @@ -79,6 +80,9 @@ def to_der(self, encoding=None, point_encoding="uncompressed"): :param str point_encoding: the point encoding of the generator when explicit curve encoding is used. Ignored for ``named_curve`` format. + + :return: DER encoded ECParameters structure + :rtype: bytes """ if encoding is None: if self.oid: @@ -117,6 +121,24 @@ def to_der(self, encoding=None, point_encoding="uncompressed"): return der.encode_sequence(*seq_elements) + def to_pem(self, encoding=None, point_encoding="uncompressed"): + """ + Serialise the curve parameters to the :term:`PEM` format. + + :param str encoding: the format to save the curve parameters in. + Default is ``named_curve``, with fallback being the ``explicit`` + if the OID is not set for the curve. + :param str point_encoding: the point encoding of the generator when + explicit curve encoding is used. Ignored for ``named_curve`` + format. + + :return: PEM encoded ECParameters structure + :rtype: str + """ + return der.topem( + self.to_der(encoding, point_encoding), "EC PARAMETERS" + ) + @staticmethod def from_der(data): """Decode the curve parameters from DER file. @@ -190,6 +212,21 @@ def from_der(data): return i return tmp_curve + @classmethod + def from_pem(cls, string): + """Decode the curve parameters from PEM file. + + :param str string: the text string to decode the parameters from + """ + if not PY2 and isinstance(string, str): # pragma: no branch + string = string.encode() + + ec_param_index = string.find(b"-----BEGIN EC PARAMETERS-----") + if ec_param_index == -1: + raise der.UnexpectedDER("EC PARAMETERS PEM header not found") + + return cls.from_der(der.unpem(string[ec_param_index:])) + # the SEC curves SECP112r1 = Curve( diff --git a/src/ecdsa/test_curves.py b/src/ecdsa/test_curves.py index 06a0d742..2394aed8 100644 --- a/src/ecdsa/test_curves.py +++ b/src/ecdsa/test_curves.py @@ -24,6 +24,45 @@ def setUpClass(cls): "AiEA/////wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQE=" ) + def test_from_pem(self): + pem_params = ( + "-----BEGIN EC PARAMETERS-----\n" + "MIHgAgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP/////////\n" + "//////zBEBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12K\n" + "o6k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsEQQRrF9Hy4SxCR/i85uVjpEDyd\n" + "wN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1\n" + "AiEA/////wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQE=\n" + "-----END EC PARAMETERS-----\n" + ) + curve = Curve.from_pem(pem_params) + + self.assertIs(curve, NIST256p) + + def test_from_pem_with_wrong_header(self): + pem_params = ( + "-----BEGIN PARAMETERS-----\n" + "MIHgAgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP/////////\n" + "//////zBEBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12K\n" + "o6k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsEQQRrF9Hy4SxCR/i85uVjpEDyd\n" + "wN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1\n" + "AiEA/////wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQE=\n" + "-----END PARAMETERS-----\n" + ) + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_pem(pem_params) + + self.assertIn("PARAMETERS PEM header", str(e.exception)) + + def test_to_pem(self): + pem_params = ( + b"-----BEGIN EC PARAMETERS-----\n" + b"BggqhkjOPQMBBw==\n" + b"-----END EC PARAMETERS-----\n" + ) + encoding = NIST256p.to_pem() + + self.assertEqual(pem_params, encoding) + def test_compare_with_different_object(self): self.assertNotEqual(NIST256p, 256) From 2574d215ee5e6115235dd0b941e3b5b341763e76 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Mon, 3 May 2021 23:41:23 +0200 Subject: [PATCH 4/8] support for limiting acceptable curve encodings as some standards, like PKIX in X.509 certificates, don't allow for explicit curve paramters, provide an API that limits the supported formats --- src/ecdsa/curves.py | 35 ++++++++++++++++++++++++++++++----- src/ecdsa/test_curves.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/ecdsa/curves.py b/src/ecdsa/curves.py index 3a8b45da..7a4a530a 100644 --- a/src/ecdsa/curves.py +++ b/src/ecdsa/curves.py @@ -140,19 +140,36 @@ def to_pem(self, encoding=None, point_encoding="uncompressed"): ) @staticmethod - def from_der(data): + def from_der(data, valid_encodings=None): """Decode the curve parameters from DER file. :param data: the binary string to decode the parameters from - :type data: bytes-like object + :type data: :term:`bytes-like object` + :param valid_encodings: set of names of allowed encodings, by default + all (set by passing ``None``), supported ones are ``named_curve`` + and ``explicit`` + :type valid_encodings: :term:`set-like object` """ + if not valid_encodings: + valid_encodings = set(("named_curve", "explicit")) + if not all(i in ["named_curve", "explicit"] for i in valid_encodings): + raise ValueError( + "Only named_curve and explicit encodings supported" + ) data = normalise_bytes(data) if not der.is_sequence(data): + if "named_curve" not in valid_encodings: + raise der.UnexpectedDER( + "named_curve curve parameters not allowed" + ) oid, empty = der.remove_object(data) if empty: raise der.UnexpectedDER("Unexpected data after OID") return find_curve(oid) + if "explicit" not in valid_encodings: + raise der.UnexpectedDER("explicit curve parameters not allowed") + seq, empty = der.remove_sequence(data) if empty: raise der.UnexpectedDER( @@ -168,7 +185,9 @@ def from_der(data): order, rest = der.remove_integer(rest) cofactor = None if rest: - cofactor, rest = der.remove_integer(rest) + # the ASN.1 specification of ECParameters allows for future + # extensions of the sequence, so ignore the remaining bytes + cofactor, _ = der.remove_integer(rest) # decode the ECParameters.fieldID sequence field_type, rest = der.remove_object(field_id) @@ -213,10 +232,14 @@ def from_der(data): return tmp_curve @classmethod - def from_pem(cls, string): + def from_pem(cls, string, valid_encodings=None): """Decode the curve parameters from PEM file. :param str string: the text string to decode the parameters from + :param valid_encodings: set of names of allowed encodings, by default + all (set by passing ``None``), supported ones are ``named_curve`` + and ``explicit`` + :type valid_encodings: :term:`set-like object` """ if not PY2 and isinstance(string, str): # pragma: no branch string = string.encode() @@ -225,7 +248,9 @@ def from_pem(cls, string): if ec_param_index == -1: raise der.UnexpectedDER("EC PARAMETERS PEM header not found") - return cls.from_der(der.unpem(string[ec_param_index:])) + return cls.from_der( + der.unpem(string[ec_param_index:]), valid_encodings + ) # the SEC curves diff --git a/src/ecdsa/test_curves.py b/src/ecdsa/test_curves.py index 2394aed8..69990c9a 100644 --- a/src/ecdsa/test_curves.py +++ b/src/ecdsa/test_curves.py @@ -38,6 +38,32 @@ def test_from_pem(self): self.assertIs(curve, NIST256p) + def test_from_pem_with_explicit_when_explicit_disabled(self): + pem_params = ( + "-----BEGIN EC PARAMETERS-----\n" + "MIHgAgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP/////////\n" + "//////zBEBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12K\n" + "o6k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsEQQRrF9Hy4SxCR/i85uVjpEDyd\n" + "wN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1\n" + "AiEA/////wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQE=\n" + "-----END EC PARAMETERS-----\n" + ) + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_pem(pem_params, ["named_curve"]) + + self.assertIn("explicit curve parameters not", str(e.exception)) + + def test_from_pem_with_named_curve_with_named_curve_disabled(self): + pem_params = ( + "-----BEGIN EC PARAMETERS-----\n" + "BggqhkjOPQMBBw==\n" + "-----END EC PARAMETERS-----\n" + ) + with self.assertRaises(der.UnexpectedDER) as e: + Curve.from_pem(pem_params, ["explicit"]) + + self.assertIn("named_curve curve parameters not", str(e.exception)) + def test_from_pem_with_wrong_header(self): pem_params = ( "-----BEGIN PARAMETERS-----\n" @@ -119,6 +145,12 @@ def test_decoding_well_known_from_explicit_params(self): self.assertIs(curve, NIST256p) + def test_decoding_with_incorrect_valid_encodings(self): + with self.assertRaises(ValueError) as e: + Curve.from_der(b"", ["explicitCA"]) + + self.assertIn("Only named_curve", str(e.exception)) + def test_compare_curves_with_different_generators(self): curve_fp = CurveFp(23, 1, 7) base_a = PointJacobi(curve_fp, 13, 3, 1, 9, generator=True) From c7e285a102ef2e06ace32e838318bf11d628d969 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Tue, 4 May 2021 00:53:28 +0200 Subject: [PATCH 5/8] support reading keys with explicitly encoded curve parameters --- src/ecdsa/keys.py | 72 ++++++++++++++++++++++------------ src/ecdsa/test_keys.py | 87 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 6d44cb35..19f3b49e 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -77,7 +77,7 @@ from . import der from . import rfc6979 from . import ellipticcurve -from .curves import NIST192p, find_curve +from .curves import NIST192p, Curve from .ecdsa import RSZeroError from .util import string_to_number, number_to_string, randrange from .util import sigencode_string, sigdecode_string, bit_length @@ -315,7 +315,13 @@ def from_string( return cls.from_public_point(point, curve, hashfunc, validate_point) @classmethod - def from_pem(cls, string, hashfunc=sha1, valid_encodings=None): + def from_pem( + cls, + string, + hashfunc=sha1, + valid_encodings=None, + valid_curve_encodings=None, + ): """ Initialise from public key stored in :term:`PEM` format. @@ -324,7 +330,7 @@ def from_pem(cls, string, hashfunc=sha1, valid_encodings=None): See the :func:`~VerifyingKey.from_der()` method for details of the format supported. - Note: only a single PEM object encoding is supported in provided + Note: only a single PEM object decoding is supported in provided string. :param string: text with PEM-encoded public ECDSA key @@ -334,6 +340,11 @@ def from_pem(cls, string, hashfunc=sha1, valid_encodings=None): :term:`hybrid`. To read malformed files, include :term:`raw encoding` with ``raw`` in the list. :type valid_encodings: :term:`set-like object + :param valid_curve_encodings: list of allowed encoding formats + for curve parameters. By default (``None``) all are supported: + ``named_curve`` and ``explicit``. + :type valid_curve_encodings: :term:`set-like object` + :return: Initialised VerifyingKey object :rtype: VerifyingKey @@ -342,10 +353,17 @@ def from_pem(cls, string, hashfunc=sha1, valid_encodings=None): der.unpem(string), hashfunc=hashfunc, valid_encodings=valid_encodings, + valid_curve_encodings=valid_curve_encodings, ) @classmethod - def from_der(cls, string, hashfunc=sha1, valid_encodings=None): + def from_der( + cls, + string, + hashfunc=sha1, + valid_encodings=None, + valid_curve_encodings=None, + ): """ Initialise the key stored in :term:`DER` format. @@ -375,6 +393,10 @@ def from_der(cls, string, hashfunc=sha1, valid_encodings=None): :term:`hybrid`. To read malformed files, include :term:`raw encoding` with ``raw`` in the list. :type valid_encodings: :term:`set-like object + :param valid_curve_encodings: list of allowed encoding formats + for curve parameters. By default (``None``) all are supported: + ``named_curve`` and ``explicit``. + :type valid_curve_encodings: :term:`set-like object` :return: Initialised VerifyingKey object :rtype: VerifyingKey @@ -391,18 +413,12 @@ def from_der(cls, string, hashfunc=sha1, valid_encodings=None): s2, point_str_bitstring = der.remove_sequence(s1) # s2 = oid_ecPublicKey,oid_curve oid_pk, rest = der.remove_object(s2) - oid_curve, empty = der.remove_object(rest) - if empty != b"": - raise der.UnexpectedDER( - "trailing junk after DER pubkey objects: %s" - % binascii.hexlify(empty) - ) if not oid_pk == oid_ecPublicKey: raise der.UnexpectedDER( "Unexpected object identifier in DER " "encoding: {0!r}".format(oid_pk) ) - curve = find_curve(oid_curve) + curve = Curve.from_der(rest, valid_curve_encodings) point_str, empty = der.remove_bitstring(point_str_bitstring, 0) if empty != b"": raise der.UnexpectedDER( @@ -849,7 +865,7 @@ def from_string(cls, string, curve=NIST192p, hashfunc=sha1): return cls.from_secret_exponent(secexp, curve, hashfunc) @classmethod - def from_pem(cls, string, hashfunc=sha1): + def from_pem(cls, string, hashfunc=sha1, valid_curve_encodings=None): """ Initialise from key stored in :term:`PEM` format. @@ -869,6 +885,11 @@ def from_pem(cls, string, hashfunc=sha1): :param string: text with PEM-encoded private ECDSA key :type string: str + :param valid_curve_encodings: list of allowed encoding formats + for curve parameters. By default (``None``) all are supported: + ``named_curve`` and ``explicit``. + :type valid_curve_encodings: :term:`set-like object` + :raises MalformedPointError: if the length of encoding doesn't match the provided curve or the encoded values is too large @@ -889,10 +910,14 @@ def from_pem(cls, string, hashfunc=sha1): if private_key_index == -1: private_key_index = string.index(b"-----BEGIN PRIVATE KEY-----") - return cls.from_der(der.unpem(string[private_key_index:]), hashfunc) + return cls.from_der( + der.unpem(string[private_key_index:]), + hashfunc, + valid_curve_encodings, + ) @classmethod - def from_der(cls, string, hashfunc=sha1): + def from_der(cls, string, hashfunc=sha1, valid_curve_encodings=None): """ Initialise from key stored in :term:`DER` format. @@ -913,8 +938,8 @@ def from_der(cls, string, hashfunc=sha1): `publicKey` field is ignored completely (errors, if any, in it will be undetected). - The only format supported for the `parameters` field is the named - curve method. Explicit encoding of curve parameters is not supported. + Two formats are supported for the `parameters` field: the named + curve and the explicit encoding of curve parameters. In the legacy ssleay format, this implementation requires the optional `parameters` field to get the curve name. In PKCS #8 format, the curve is part of the PrivateKeyAlgorithmIdentifier. @@ -937,6 +962,10 @@ def from_der(cls, string, hashfunc=sha1): :param string: binary string with DER-encoded private ECDSA key :type string: bytes like object + :param valid_curve_encodings: list of allowed encoding formats + for curve parameters. By default (``None``) all are supported: + ``named_curve`` and ``explicit``. + :type valid_curve_encodings: :term:`set-like object` :raises MalformedPointError: if the length of encoding doesn't match the provided curve or the encoded values is too large @@ -971,8 +1000,7 @@ def from_der(cls, string, hashfunc=sha1): sequence, s = der.remove_sequence(s) algorithm_oid, algorithm_identifier = der.remove_object(sequence) - curve_oid, empty = der.remove_object(algorithm_identifier) - curve = find_curve(curve_oid) + curve = Curve.from_der(algorithm_identifier, valid_curve_encodings) if algorithm_oid not in (oid_ecPublicKey, oid_ecDH, oid_ecMQV): raise der.UnexpectedDER( @@ -1014,13 +1042,7 @@ def from_der(cls, string, hashfunc=sha1): raise der.UnexpectedDER( "expected tag 0 in DER privkey, got %d" % tag ) - curve_oid, empty = der.remove_object(curve_oid_str) - if empty != b(""): - raise der.UnexpectedDER( - "trailing junk after DER privkey " - "curve_oid: %s" % binascii.hexlify(empty) - ) - curve = find_curve(curve_oid) + curve = Curve.from_der(curve_oid_str, valid_curve_encodings) # we don't actually care about the following fields # diff --git a/src/ecdsa/test_keys.py b/src/ecdsa/test_keys.py index 31353f45..0cf7cc9a 100644 --- a/src/ecdsa/test_keys.py +++ b/src/ecdsa/test_keys.py @@ -14,7 +14,7 @@ import hashlib from .keys import VerifyingKey, SigningKey, MalformedPointError -from .der import unpem +from .der import unpem, UnexpectedDER from .util import ( sigencode_string, sigencode_der, @@ -153,6 +153,47 @@ def setUpClass(cls): cls.sk2 = SigningKey.generate(vk.curve) + def test_load_key_with_explicit_parameters(self): + pub_key_str = ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBSzCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAA\n" + "AAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA////\n" + "///////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSd\n" + "NgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5\n" + "RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA\n" + "//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABIr1UkgYs5jmbFc7it1/YI2X\n" + "T//IlaEjMNZft1owjqpBYH2ErJHk4U5Pp4WvWq1xmHwIZlsH7Ig4KmefCfR6SmU=\n" + "-----END PUBLIC KEY-----" + ) + pk = VerifyingKey.from_pem(pub_key_str) + + pk_exp = VerifyingKey.from_string( + b"\x04\x8a\xf5\x52\x48\x18\xb3\x98\xe6\x6c\x57\x3b\x8a\xdd\x7f" + b"\x60\x8d\x97\x4f\xff\xc8\x95\xa1\x23\x30\xd6\x5f\xb7\x5a\x30" + b"\x8e\xaa\x41\x60\x7d\x84\xac\x91\xe4\xe1\x4e\x4f\xa7\x85\xaf" + b"\x5a\xad\x71\x98\x7c\x08\x66\x5b\x07\xec\x88\x38\x2a\x67\x9f" + b"\x09\xf4\x7a\x4a\x65", + curve=NIST256p, + ) + self.assertEqual(pk, pk_exp) + + def test_load_key_with_explicit_with_explicit_disabled(self): + pub_key_str = ( + "-----BEGIN PUBLIC KEY-----\n" + "MIIBSzCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAABAAAA\n" + "AAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA////\n" + "///////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMVAMSd\n" + "NgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5\n" + "RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8AAAAA\n" + "//////////+85vqtpxeehPO5ysL8YyVRAgEBA0IABIr1UkgYs5jmbFc7it1/YI2X\n" + "T//IlaEjMNZft1owjqpBYH2ErJHk4U5Pp4WvWq1xmHwIZlsH7Ig4KmefCfR6SmU=\n" + "-----END PUBLIC KEY-----" + ) + with self.assertRaises(UnexpectedDER): + VerifyingKey.from_pem( + pub_key_str, valid_curve_encodings=["named_curve"] + ) + def test_load_key_with_disabled_format(self): with self.assertRaises(MalformedPointError) as e: VerifyingKey.from_der(self.key_bytes, valid_encodings=["raw"]) @@ -263,6 +304,50 @@ def setUpClass(cls): ) cls.sk2 = SigningKey.from_pem(prv_key_str) + def test_decoding_explicit_curve_parameters(self): + prv_key_str = ( + "-----BEGIN PRIVATE KEY-----\n" + "MIIBeQIBADCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAAB\n" + "AAAAAAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA\n" + "///////////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMV\n" + "AMSdNgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg\n" + "9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8A\n" + "AAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBBG0wawIBAQQgIXtREfUmR16r\n" + "ZbmvDGD2lAEFPZa2DLPyz0czSja58yChRANCAASK9VJIGLOY5mxXO4rdf2CNl0//\n" + "yJWhIzDWX7daMI6qQWB9hKyR5OFOT6eFr1qtcZh8CGZbB+yIOCpnnwn0ekpl\n" + "-----END PRIVATE KEY-----\n" + ) + + sk = SigningKey.from_pem(prv_key_str) + + sk2 = SigningKey.from_string( + b"\x21\x7b\x51\x11\xf5\x26\x47\x5e\xab\x65\xb9\xaf\x0c\x60\xf6" + b"\x94\x01\x05\x3d\x96\xb6\x0c\xb3\xf2\xcf\x47\x33\x4a\x36\xb9" + b"\xf3\x20", + curve=NIST256p, + ) + + self.assertEqual(sk, sk2) + + def test_decoding_explicit_curve_parameters_with_explicit_disabled(self): + prv_key_str = ( + "-----BEGIN PRIVATE KEY-----\n" + "MIIBeQIBADCCAQMGByqGSM49AgEwgfcCAQEwLAYHKoZIzj0BAQIhAP////8AAAAB\n" + "AAAAAAAAAAAAAAAA////////////////MFsEIP////8AAAABAAAAAAAAAAAAAAAA\n" + "///////////////8BCBaxjXYqjqT57PrvVV2mIa8ZR0GsMxTsPY7zjw+J9JgSwMV\n" + "AMSdNgiG5wSTamZ44ROdJreBn36QBEEEaxfR8uEsQkf4vOblY6RA8ncDfYEt6zOg\n" + "9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9QIhAP////8A\n" + "AAAA//////////+85vqtpxeehPO5ysL8YyVRAgEBBG0wawIBAQQgIXtREfUmR16r\n" + "ZbmvDGD2lAEFPZa2DLPyz0czSja58yChRANCAASK9VJIGLOY5mxXO4rdf2CNl0//\n" + "yJWhIzDWX7daMI6qQWB9hKyR5OFOT6eFr1qtcZh8CGZbB+yIOCpnnwn0ekpl\n" + "-----END PRIVATE KEY-----\n" + ) + + with self.assertRaises(UnexpectedDER): + SigningKey.from_pem( + prv_key_str, valid_curve_encodings=["named_curve"] + ) + def test_equality_on_signing_keys(self): sk = SigningKey.from_secret_exponent( self.sk1.privkey.secret_multiplier, self.sk1.curve From 717c04cad4ccc6441a644530a847d611adb2d1e2 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Tue, 4 May 2021 02:36:16 +0200 Subject: [PATCH 6/8] add support for writing keys with explicit curve parameters --- src/ecdsa/keys.py | 72 ++++++++++++++++++++++++++++++++------- src/ecdsa/test_pyecdsa.py | 24 +++++++++++++ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 19f3b49e..29c77d13 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -564,7 +564,9 @@ def to_string(self, encoding="raw"): assert encoding in ("raw", "uncompressed", "compressed", "hybrid") return self.pubkey.point.to_bytes(encoding) - def to_pem(self, point_encoding="uncompressed"): + def to_pem( + self, point_encoding="uncompressed", curve_parameters_encoding=None + ): """ Convert the public key to the :term:`PEM` format. @@ -578,6 +580,9 @@ def to_pem(self, point_encoding="uncompressed"): of public keys. "uncompressed" is most portable, "compressed" is smallest. "hybrid" is uncommon and unsupported by most implementations, it is as big as "uncompressed". + :param str curve_parameters_encoding: the encoding for curve parameters + to use, by default tries to use ``named_curve`` encoding, + if that is not possible, falls back to ``named_curve`` encoding. :return: portable encoding of the public key :rtype: bytes @@ -585,9 +590,14 @@ def to_pem(self, point_encoding="uncompressed"): .. warning:: The PEM is encoded to US-ASCII, it needs to be re-encoded if the system is incompatible (e.g. uses UTF-16) """ - return der.topem(self.to_der(point_encoding), "PUBLIC KEY") + return der.topem( + self.to_der(point_encoding, curve_parameters_encoding), + "PUBLIC KEY", + ) - def to_der(self, point_encoding="uncompressed"): + def to_der( + self, point_encoding="uncompressed", curve_parameters_encoding=None + ): """ Convert the public key to the :term:`DER` format. @@ -599,6 +609,9 @@ def to_der(self, point_encoding="uncompressed"): of public keys. "uncompressed" is most portable, "compressed" is smallest. "hybrid" is uncommon and unsupported by most implementations, it is as big as "uncompressed". + :param str curve_parameters_encoding: the encoding for curve parameters + to use, by default tries to use ``named_curve`` encoding, + if that is not possible, falls back to ``named_curve`` encoding. :return: DER encoding of the public key :rtype: bytes @@ -608,7 +621,8 @@ def to_der(self, point_encoding="uncompressed"): point_str = self.to_string(point_encoding) return der.encode_sequence( der.encode_sequence( - encoded_oid_ecPublicKey, self.curve.encoded_oid + encoded_oid_ecPublicKey, + self.curve.to_der(curve_parameters_encoding), ), # 0 is the number of unused bits in the # bit string @@ -1078,7 +1092,12 @@ def to_string(self): s = number_to_string(secexp, self.privkey.order) return s - def to_pem(self, point_encoding="uncompressed", format="ssleay"): + def to_pem( + self, + point_encoding="uncompressed", + format="ssleay", + curve_parameters_encoding=None, + ): """ Convert the private key to the :term:`PEM` format. @@ -1092,6 +1111,11 @@ def to_pem(self, point_encoding="uncompressed", format="ssleay"): :param str point_encoding: format to use for encoding public point :param str format: either ``ssleay`` (default) or ``pkcs8`` + :param str curve_parameters_encoding: format of encoded curve + parameters, default depends on the curve, if the curve has + an associated OID, ``named_curve`` format will be used, + if no OID is associated with the curve, the fallback of + ``explicit`` parameters will be used. :return: PEM encoded private key :rtype: bytes @@ -1102,9 +1126,17 @@ def to_pem(self, point_encoding="uncompressed", format="ssleay"): # TODO: "BEGIN ECPARAMETERS" assert format in ("ssleay", "pkcs8") header = "EC PRIVATE KEY" if format == "ssleay" else "PRIVATE KEY" - return der.topem(self.to_der(point_encoding, format), header) + return der.topem( + self.to_der(point_encoding, format, curve_parameters_encoding), + header, + ) - def to_der(self, point_encoding="uncompressed", format="ssleay"): + def to_der( + self, + point_encoding="uncompressed", + format="ssleay", + curve_parameters_encoding=None, + ): """ Convert the private key to the :term:`DER` format. @@ -1115,6 +1147,11 @@ def to_der(self, point_encoding="uncompressed", format="ssleay"): :param str point_encoding: format to use for encoding public point :param str format: either ``ssleay`` (default) or ``pkcs8`` + :param str curve_parameters_encoding: format of encoded curve + parameters, default depends on the curve, if the curve has + an associated OID, ``named_curve`` format will be used, + if no OID is associated with the curve, the fallback of + ``explicit`` parameters will be used. :return: DER encoded private key :rtype: bytes @@ -1125,14 +1162,22 @@ def to_der(self, point_encoding="uncompressed", format="ssleay"): raise ValueError("raw encoding not allowed in DER") assert format in ("ssleay", "pkcs8") encoded_vk = self.get_verifying_key().to_string(point_encoding) - # the 0 in encode_bitstring specifies the number of unused bits - # in the `encoded_vk` string - ec_private_key = der.encode_sequence( + priv_key_elems = [ der.encode_integer(1), der.encode_octet_string(self.to_string()), - der.encode_constructed(0, self.curve.encoded_oid), - der.encode_constructed(1, der.encode_bitstring(encoded_vk, 0)), + ] + if format == "ssleay": + priv_key_elems.append( + der.encode_constructed( + 0, self.curve.to_der(curve_parameters_encoding) + ) + ) + # the 0 in encode_bitstring specifies the number of unused bits + # in the `encoded_vk` string + priv_key_elems.append( + der.encode_constructed(1, der.encode_bitstring(encoded_vk, 0)) ) + ec_private_key = der.encode_sequence(*priv_key_elems) if format == "ssleay": return ec_private_key @@ -1142,7 +1187,8 @@ def to_der(self, point_encoding="uncompressed", format="ssleay"): # top-level structure. der.encode_integer(1), der.encode_sequence( - der.encode_oid(*oid_ecPublicKey), self.curve.encoded_oid + der.encode_oid(*oid_ecPublicKey), + self.curve.to_der(curve_parameters_encoding), ), der.encode_octet_string(ec_private_key), ) diff --git a/src/ecdsa/test_pyecdsa.py b/src/ecdsa/test_pyecdsa.py index 194e5e4c..c1393bcc 100644 --- a/src/ecdsa/test_pyecdsa.py +++ b/src/ecdsa/test_pyecdsa.py @@ -1319,6 +1319,17 @@ def do_test_to_openssl(self, curve, hash_name="SHA1"): % mdarg ) + with open("t/privkey-explicit.pem", "wb") as e: + e.write(sk.to_pem(curve_parameters_encoding="explicit")) + run_openssl( + "dgst %s -sign t/privkey-explicit.pem -out t/data.sig2 t/data.txt" + % mdarg + ) + run_openssl( + "dgst %s -verify t/pubkey.pem -signature t/data.sig2 t/data.txt" + % mdarg + ) + with open("t/privkey-p8.pem", "wb") as e: e.write(sk.to_pem(format="pkcs8")) run_openssl( @@ -1330,6 +1341,19 @@ def do_test_to_openssl(self, curve, hash_name="SHA1"): % mdarg ) + with open("t/privkey-p8-explicit.pem", "wb") as e: + e.write( + sk.to_pem(format="pkcs8", curve_parameters_encoding="explicit") + ) + run_openssl( + "dgst %s -sign t/privkey-p8-explicit.pem -out t/data.sig3 t/data.txt" + % mdarg + ) + run_openssl( + "dgst %s -verify t/pubkey.pem -signature t/data.sig3 t/data.txt" + % mdarg + ) + class TooSmallCurve(unittest.TestCase): OPENSSL_SUPPORTED_CURVES = set( From 77cabc01acc02d96b83c1bbb7b91b5447393286e Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Tue, 4 May 2021 02:39:51 +0200 Subject: [PATCH 7/8] add tox environment for formatting code with black --- tox.ini | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tox.ini b/tox.ini index 742f3fbb..1550ac5a 100644 --- a/tox.ini +++ b/tox.ini @@ -96,6 +96,13 @@ commands = flake8 setup.py speed.py src black --check --line-length 79 . +[testenv:codeformat] +basepython = python3 +deps = + black==19.10b0 +commands = + black --line-length 79 . + [flake8] exclude = src/ecdsa/test*.py # We're just getting started. For now, ignore the following problems: From 1ea4aef64abb0a6a59f15f5727f6492374433eab Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Fri, 21 May 2021 17:11:43 +0200 Subject: [PATCH 8/8] fix CI on py3.4 --- build-requirements-3.4.txt | 1 + tox.ini | 1 + 2 files changed, 2 insertions(+) diff --git a/build-requirements-3.4.txt b/build-requirements-3.4.txt index ce3e0fec..ee734d11 100644 --- a/build-requirements-3.4.txt +++ b/build-requirements-3.4.txt @@ -4,3 +4,4 @@ hypothesis pytest>=4.6.0 PyYAML<5.3 coverage +attrs<21 diff --git a/tox.ini b/tox.ini index 1550ac5a..3ae7bd70 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ deps = py{33}: hypothesis<3.44 py{26}: unittest2 py{26}: hypothesis<3 + py{34}: attrs<21 py{26,27,34,35,36,37,38,39,py,py3}: pytest py{27,34,35,36,37,38,39,py,py3}: hypothesis gmpy2py{27,39}: gmpy2