From d47a238126a5140f73e46a731ed0905c6eb5b504 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Sun, 29 Sep 2019 00:33:02 +0200 Subject: [PATCH 1/4] support for SEC/X9.62 formatted keys Adds support for encoding and decoding verifying keys in format specified in SEC 1 or in X9.62. Specifically the uncompressed point encoding and the compressed point encoding --- src/ecdsa/keys.py | 82 ++++++++++++++++++++++++++---- src/ecdsa/numbertheory.py | 23 ++++++--- src/ecdsa/test_pyecdsa.py | 104 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index e56c5d48..13482717 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -3,7 +3,9 @@ from . import ecdsa from . import der 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 @@ -23,6 +25,10 @@ class BadDigestError(Exception): pass +class MalformedPointError(AssertionError): + pass + + class VerifyingKey: def __init__(self, _error__please_use_generate=None): if not _error__please_use_generate: @@ -38,9 +44,8 @@ def from_public_point(klass, point, curve=NIST192p, hashfunc=sha1): self.pubkey.order = curve.order return self - @classmethod - def from_string(klass, string, curve=NIST192p, hashfunc=sha1, - validate_point=True): + @staticmethod + def _from_raw_encoding(string, curve, validate_point): order = curve.order assert (len(string) == curve.verifying_key_length), \ (len(string), curve.verifying_key_length) @@ -52,8 +57,50 @@ def from_string(klass, string, curve=NIST192p, hashfunc=sha1, y = string_to_number(ys) if validate_point: assert ecdsa.point_is_valid(curve.generator, x, y) - from . import ellipticcurve - point = ellipticcurve.Point(curve.curve, x, y, order) + return ellipticcurve.Point(curve.curve, x, y, order) + + @staticmethod + def _from_compressed(string, curve, validate_point): + 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 + if validate_point and not ecdsa.point_is_valid(curve.generator, x, y): + raise MalformedPointError("Point does not lie on curve") + return ellipticcurve.Point(curve.curve, x, y, order) + + @classmethod + def from_string(klass, string, curve=NIST192p, hashfunc=sha1, + validate_point=True): + sig_len = len(string) + if sig_len == curve.verifying_key_length: + point = klass._from_raw_encoding(string, curve, validate_point) + elif sig_len == curve.verifying_key_length + 1: + if string[:1] != b('\x04'): + raise MalformedPointError( + "Invalid uncompressed encoding of the public point") + point = klass._from_raw_encoding(string[1:], curve, validate_point) + elif sig_len == curve.baselen + 1: + point = klass._from_compressed(string, curve, validate_point) + else: + raise MalformedPointError( + "Length of string does not match lengths of " + "any of the supported encodings of {0} " + "curve.".format(curve.name)) + return klass.from_public_point(point, curve, hashfunc) @classmethod @@ -110,15 +157,32 @@ def from_public_key_recovery_with_digest(klass, signature, digest, curve, hashfu verifying_keys = [klass.from_public_point(pk.point, curve, hashfunc) for pk in pks] return verifying_keys - def to_string(self): - # VerifyingKey.from_string(vk.to_string()) == vk as long as the - # curves are the same: the curve itself is not included in the - # serialized form + def _raw_encode(self): order = self.pubkey.order 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): + order = self.pubkey.order + 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 to_string(self, encoding="raw"): + # VerifyingKey.from_string(vk.to_string()) == vk as long as the + # curves are the same: the curve itself is not included in the + # serialized form + assert encoding in ("raw", "uncompressed", "compressed") + if encoding == "raw": + return self._raw_encode() + elif encoding == "uncompressed": + return b('\x04') + self._raw_encode() + else: + return self._compressed_encode() + def to_pem(self): return der.topem(self.to_der(), "PUBLIC KEY") diff --git a/src/ecdsa/numbertheory.py b/src/ecdsa/numbertheory.py index 2b49725f..d7b906e3 100644 --- a/src/ecdsa/numbertheory.py +++ b/src/ecdsa/numbertheory.py @@ -11,8 +11,12 @@ from __future__ import division -from six import integer_types +from six import integer_types, PY3 from six.moves import reduce +try: + xrange +except NameError: + xrange = range import math @@ -62,7 +66,7 @@ def polynomial_reduce_mod(poly, polymod, p): while len(poly) >= len(polymod): if poly[-1] != 0: - for i in range(2, len(polymod) + 1): + for i in xrange(2, len(polymod) + 1): poly[-i] = (poly[-i] - poly[-1] * polymod[-i]) % p poly = poly[0:-1] @@ -86,8 +90,8 @@ def polynomial_multiply_mod(m1, m2, polymod, p): # Add together all the cross-terms: - for i in range(len(m1)): - for j in range(len(m2)): + for i in xrange(len(m1)): + for j in xrange(len(m2)): prod[i + j] = (prod[i + j] + m1[i] * m2[j]) % p return polynomial_reduce_mod(prod, polymod, p) @@ -187,7 +191,12 @@ def square_root_mod_prime(a, p): return (2 * a * modular_exp(4 * a, (p - 5) // 8, p)) % p raise RuntimeError("Shouldn't get here.") - for b in range(2, p): + if PY3: + range_top = p + else: + # xrange on python2 can take integers representable as C long only + range_top = min(0x7fffffff, p) + for b in xrange(2, range_top): if jacobi(b * b - 4 * a, p) == -1: f = (a, -b, 1) ff = polynomial_exp_mod((0, 1), (p + 1) // 2, f, p) @@ -355,7 +364,7 @@ def carmichael_of_factorized(f_list): return 1 result = carmichael_of_ppower(f_list[0]) - for i in range(1, len(f_list)): + for i in xrange(1, len(f_list)): result = lcm(result, carmichael_of_ppower(f_list[i])) return result @@ -477,7 +486,7 @@ def is_prime(n): while (r % 2) == 0: s = s + 1 r = r // 2 - for i in range(t): + for i in xrange(t): a = smallprimes[i] y = modular_exp(a, r, n) if y != 1 and y != n - 1: diff --git a/src/ecdsa/test_pyecdsa.py b/src/ecdsa/test_pyecdsa.py index 125186fa..4c385c19 100644 --- a/src/ecdsa/test_pyecdsa.py +++ b/src/ecdsa/test_pyecdsa.py @@ -1,6 +1,9 @@ from __future__ import with_statement, division -import unittest +try: + import unittest2 as unittest +except ImportError: + import unittest import os import time import shutil @@ -11,12 +14,14 @@ from six import b, print_, binary_type from .keys import SigningKey, VerifyingKey -from .keys import BadSignatureError +from .keys import BadSignatureError, MalformedPointError from . import util from .util import sigencode_der, sigencode_strings from .util import sigdecode_der, sigdecode_strings +from .util import number_to_string from .curves import Curve, UnknownCurveError -from .curves import NIST192p, NIST224p, NIST256p, NIST384p, NIST521p, SECP256k1 +from .curves import NIST192p, NIST224p, NIST256p, NIST384p, NIST521p, \ + SECP256k1, curves from .ellipticcurve import Point from . import der from . import rfc6979 @@ -367,6 +372,99 @@ def test_public_key_recovery_with_custom_hash(self): self.assertTrue(vk.pubkey.point in [recovered_vk.pubkey.point for recovered_vk in recovered_vks]) + def test_encoding(self): + sk = SigningKey.from_secret_exponent(123456789) + vk = sk.verifying_key + + exp = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + self.assertEqual(vk.to_string(), exp) + self.assertEqual(vk.to_string('uncompressed'), b('\x04') + exp) + self.assertEqual(vk.to_string('compressed'), b('\x02') + exp[:24]) + + def test_decoding(self): + sk = SigningKey.from_secret_exponent(123456789) + vk = sk.verifying_key + + enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + + from_raw = VerifyingKey.from_string(enc) + self.assertEqual(from_raw.pubkey.point, vk.pubkey.point) + + from_uncompressed = VerifyingKey.from_string(b('\x04') + enc) + self.assertEqual(from_uncompressed.pubkey.point, vk.pubkey.point) + + from_compressed = VerifyingKey.from_string(b('\x02') + enc[:24]) + self.assertEqual(from_compressed.pubkey.point, vk.pubkey.point) + + def test_decoding_with_malformed_uncompressed(self): + enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(b('\x02') + enc) + + def test_decoding_with_malformed_compressed(self): + enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(b('\x01') + enc[:24]) + + def test_decoding_with_point_at_infinity(self): + # decoding it is unsupported, as it's not necessary to encode it + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(b('\x00')) + + def test_not_lying_on_curve(self): + enc = number_to_string(NIST192p.order, NIST192p.order+1) + + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(b('\x02') + enc) + + +@pytest.mark.parametrize("val,even", + [(i, j) for i in range(256) for j in [True, False]]) +def test_VerifyingKey_decode_with_small_values(val, even): + enc = number_to_string(val, NIST192p.order) + + if even: + enc = b('\x02') + enc + else: + enc = b('\x03') + enc + + # small values can both be actual valid public keys and not, verify that + # only expected exceptions are raised if they are not + try: + vk = VerifyingKey.from_string(enc) + assert isinstance(vk, VerifyingKey) + except MalformedPointError: + assert True + + +params = [] +for curve in curves: + for enc in ["raw", "uncompressed", "compressed"]: + params.append(pytest.param(curve, enc, id="{0}-{1}".format( + curve.name, enc))) + + +@pytest.mark.parametrize("curve,encoding", params) +def test_VerifyingKey_encode_decode(curve, encoding): + sk = SigningKey.generate(curve=curve) + vk = sk.verifying_key + + encoded = vk.to_string(encoding) + + from_enc = VerifyingKey.from_string(encoded, curve=curve) + + assert vk.pubkey.point == from_enc.pubkey.point + class OpenSSL(unittest.TestCase): # test interoperability with OpenSSL tools. Note that openssl's ECDSA From 880d9ba3637f926dc88a50528e8bb51df6608565 Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Sun, 29 Sep 2019 00:39:47 +0200 Subject: [PATCH 2/4] unify exceptions between raw and compressed encoding make the decoding of malformed point raise the same exception irrespective of the formatting of the key/public point --- src/ecdsa/keys.py | 5 +++-- src/ecdsa/test_pyecdsa.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 13482717..8ad4915e 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -55,8 +55,9 @@ def _from_raw_encoding(string, curve, validate_point): assert len(ys) == curve.baselen, (len(ys), curve.baselen) x = string_to_number(xs) y = string_to_number(ys) - if validate_point: - assert ecdsa.point_is_valid(curve.generator, x, y) + if validate_point and not ecdsa.point_is_valid(curve.generator, x, y): + raise MalformedPointError("Point does not lie on the curve") + return ellipticcurve.Point(curve.curve, x, y, order) @staticmethod diff --git a/src/ecdsa/test_pyecdsa.py b/src/ecdsa/test_pyecdsa.py index 4c385c19..2b0cac45 100644 --- a/src/ecdsa/test_pyecdsa.py +++ b/src/ecdsa/test_pyecdsa.py @@ -416,6 +416,14 @@ def test_decoding_with_malformed_compressed(self): with self.assertRaises(MalformedPointError): VerifyingKey.from_string(b('\x01') + enc[:24]) + def test_decoding_with_point_not_on_curve(self): + enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(enc[:47] + b('\x00')) + def test_decoding_with_point_at_infinity(self): # decoding it is unsupported, as it's not necessary to encode it with self.assertRaises(MalformedPointError): From 7ca6106778a258f7c4d4069708b2d6b1805fb7da Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Sun, 29 Sep 2019 01:04:18 +0200 Subject: [PATCH 3/4] code reuse for ASN.1 formatting reuse the new to_string and from_string to support saving and reading the public key in PEM and DER files with both compressed and uncompressed point encoding --- src/ecdsa/keys.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 8ad4915e..54cc64eb 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -122,14 +122,20 @@ def from_der(klass, string): if empty != b(""): raise der.UnexpectedDER("trailing junk after DER pubkey objects: %s" % binascii.hexlify(empty)) - assert oid_pk == oid_ecPublicKey, (oid_pk, oid_ecPublicKey) + 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) point_str, empty = der.remove_bitstring(point_str_bitstring) if empty != b(""): raise der.UnexpectedDER("trailing junk after pubkey pointstring: %s" % binascii.hexlify(empty)) - assert point_str.startswith(b("\x00\x04")) - return klass.from_string(point_str[2:], curve) + # the point encoding is padded with a zero byte + # raw encoding of point is invalid in DER files + if not point_str.startswith(b("\x00")) or \ + len(point_str[1:]) == curve.verifying_key_length: + raise der.UnexpectedDER("Malformed encoding of public point") + return klass.from_string(point_str[1:], curve) @classmethod def from_public_key_recovery(cls, signature, data, curve, hashfunc=sha1, @@ -187,11 +193,11 @@ def to_string(self, encoding="raw"): def to_pem(self): return der.topem(self.to_der(), "PUBLIC KEY") - def to_der(self): + def to_der(self, point_encoding="uncompressed"): order = self.pubkey.order x_str = number_to_string(self.pubkey.point.x(), order) y_str = number_to_string(self.pubkey.point.y(), order) - point_str = b("\x00\x04") + x_str + y_str + point_str = b("\x00") + self.to_string(point_encoding) return der.encode_sequence(der.encode_sequence(encoded_oid_ecPublicKey, self.curve.encoded_oid), der.encode_bitstring(point_str)) @@ -312,10 +318,11 @@ def to_pem(self): # TODO: "BEGIN ECPARAMETERS" return der.topem(self.to_der(), "EC PRIVATE KEY") - def to_der(self): + def to_der(self, point_encoding="uncompressed"): # SEQ([int(1), octetstring(privkey),cont[0], oid(secp224r1), # cont[1],bitstring]) - encoded_vk = b("\x00\x04") + self.get_verifying_key().to_string() + encoded_vk = b("\x00") + \ + self.get_verifying_key().to_string(point_encoding) return der.encode_sequence(der.encode_integer(1), der.encode_octet_string(self.to_string()), der.encode_constructed(0, self.curve.encoded_oid), From cb15e5fc5dec7cb7fdb5886dad29b4190a18373a Mon Sep 17 00:00:00 2001 From: Hubert Kario Date: Mon, 30 Sep 2019 18:44:28 +0200 Subject: [PATCH 4/4] add support for X9.62 hybrid public key format the X9.62 standard defines also a hybrid public key representation, add support for it --- src/ecdsa/keys.py | 36 ++++++++++++++++++++++++++++++++---- src/ecdsa/test_pyecdsa.py | 15 ++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/ecdsa/keys.py b/src/ecdsa/keys.py index 54cc64eb..6439db17 100644 --- a/src/ecdsa/keys.py +++ b/src/ecdsa/keys.py @@ -83,6 +83,21 @@ def _from_compressed(string, curve, validate_point): raise MalformedPointError("Point does not lie on curve") return ellipticcurve.Point(curve.curve, x, y, order) + @classmethod + def _from_hybrid(cls, string, curve, validate_point): + 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, validate_point) + + # 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(klass, string, curve=NIST192p, hashfunc=sha1, validate_point=True): @@ -90,10 +105,14 @@ def from_string(klass, string, curve=NIST192p, hashfunc=sha1, if sig_len == curve.verifying_key_length: point = klass._from_raw_encoding(string, curve, validate_point) elif sig_len == curve.verifying_key_length + 1: - if string[:1] != b('\x04'): + if string[:1] in (b('\x06'), b('\x07')): + point = klass._from_hybrid(string, curve, validate_point) + elif string[:1] == b('\x04'): + point = klass._from_raw_encoding(string[1:], curve, + validate_point) + else: raise MalformedPointError( - "Invalid uncompressed encoding of the public point") - point = klass._from_raw_encoding(string[1:], curve, validate_point) + "Invalid X9.62 encoding of the public point") elif sig_len == curve.baselen + 1: point = klass._from_compressed(string, curve, validate_point) else: @@ -178,15 +197,24 @@ def _compressed_encode(self): else: return b('\x02') + x_str + def _hybrid_encode(self): + 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"): # VerifyingKey.from_string(vk.to_string()) == vk as long as the # curves are the same: the curve itself is not included in the # serialized form - assert encoding in ("raw", "uncompressed", "compressed") + 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() diff --git a/src/ecdsa/test_pyecdsa.py b/src/ecdsa/test_pyecdsa.py index 2b0cac45..bcca3ce8 100644 --- a/src/ecdsa/test_pyecdsa.py +++ b/src/ecdsa/test_pyecdsa.py @@ -380,8 +380,10 @@ def test_encoding(self): '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') self.assertEqual(vk.to_string(), exp) + self.assertEqual(vk.to_string('raw'), exp) self.assertEqual(vk.to_string('uncompressed'), b('\x04') + exp) self.assertEqual(vk.to_string('compressed'), b('\x02') + exp[:24]) + self.assertEqual(vk.to_string('hybrid'), b('\x06') + exp) def test_decoding(self): sk = SigningKey.from_secret_exponent(123456789) @@ -400,6 +402,9 @@ def test_decoding(self): from_compressed = VerifyingKey.from_string(b('\x02') + enc[:24]) self.assertEqual(from_compressed.pubkey.point, vk.pubkey.point) + from_uncompressed = VerifyingKey.from_string(b('\x06') + enc) + self.assertEqual(from_uncompressed.pubkey.point, vk.pubkey.point) + def test_decoding_with_malformed_uncompressed(self): enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' @@ -416,6 +421,14 @@ def test_decoding_with_malformed_compressed(self): with self.assertRaises(MalformedPointError): VerifyingKey.from_string(b('\x01') + enc[:24]) + def test_decoding_with_inconsistent_hybrid(self): + enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' + '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' + 'z\x8a#\xb5\x97\xecO\xb6\xa0HQ\x89*') + + with self.assertRaises(MalformedPointError): + VerifyingKey.from_string(b('\x07') + enc) + def test_decoding_with_point_not_on_curve(self): enc = b('\x0c\xe0\x1d\xe0d\x1c\x8eS\x8a\xc0\x9eK\xa8x !\xd5\xc2\xc3' '\xfd\xc8\xa0c\xff\xfb\x02\xb9\xc4\x84)\x1a\x0f\x8b\x87\xa4' @@ -457,7 +470,7 @@ def test_VerifyingKey_decode_with_small_values(val, even): params = [] for curve in curves: - for enc in ["raw", "uncompressed", "compressed"]: + for enc in ["raw", "uncompressed", "compressed", "hybrid"]: params.append(pytest.param(curve, enc, id="{0}-{1}".format( curve.name, enc)))