pythonecdsaelliptic-curvepython-cryptography

Compress secp256k1 elliptic curve x, y coordinates strings strings into hex public key


For context

I am using the azurerm_key_vault_key terraform resource to create an secp256k1 key. I can output the X and Y coordinates for this key as strings that look like this (appears to be a base64 url-encoded, but I cannot find any docs that clearly define this)

"x": "ARriqkpHlC1Ia1Tk86EM_bqH_9a88Oh2zMYF3fUUGJw"
"y": "wTYd3CEiwTk1n-lFPdpZ51P4Z0EzlVNXLvJMY-k55pQ"

The problem

I need to convert this into a public key in hexadecimal format. I have tried the following in Python:

import ecdsa
import binascii
import base64

def base64url_to_bytes(base64url_string):
    padding = '=' * (4 - (len(base64url_string) % 4))
    base64_string = base64url_string.replace('-', '+').replace('_', '/') + padding
    return base64.b64decode(base64_string)

def compress_point(x, y):
    """Compresses the point (x, y) to a 33-byte compressed public key."""
    prefix = "02" if y % 2 == 0 else "03"
    return prefix + x

def decompress_point(compressed_key):
    """Decompresses the compressed public key to (x, y)."""
    prefix = compressed_key[:2]
    x = compressed_key[2:]
    y = ecdsa.ellipticcurve.Point(ecdsa.SECP256k1.curve, int(x, 16), int(prefix, 16))
    return x, y.y()

x_value = base64url_to_bytes("ARriqkpHlC1Ia1Tk86EM_bqH_9a88Oh2zMYF3fUUGJw")
y_value = base64url_to_bytes("wTYd3CEiwTk1n-lFPdpZ51P4Z0EzlVNXLvJMY-k55pQ")


x, y = int.from_bytes(x_value, byteorder='big'), int.from_bytes(y_value, byteorder='big')

# Compress the point
compressed_key = compress_point(format(x, 'x'), y)
print(compressed_key)

decompressed_x, decompressed_y = decompress_point(compressed_key)
print("Decompressed X:", decompressed_x)
print("Decompressed Y:", decompressed_y)

It compresses, but decompress_point throws an assertion error, which suggests that I am doing something wrong either in the compression or decompression step.

Here is the full output of the script above:

0211ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189c
Traceback (most recent call last):
  File "get_public_key3.py", line 32, in <module>
    decompressed_x, decompressed_y = decompress_point(compressed_key)
  File "get_public_key3.py", line 19, in decompress_point
    y = ecdsa.ellipticcurve.Point(ecdsa.SECP256k1.curve, int(x, 16), int(prefix, 16))
  File "/home/ubuntu/.local/lib/python3.8/site-packages/ecdsa/ellipticcurve.py", line 1090, in __init__
    assert self.__curve.contains_point(x, y)
AssertionError

I am not at all familiar with the packages in use here and the code above has mostly been put together from various bits and pieces I could find online.

Could someone provide me with a definitive answer on how to do this?


Solution

  • Base64url is a common encoding, your x and y look formally Base64url encoded. Also, a Base64url decoding provides an EC point valid for secp256k1:

    ...
    x_value = base64url_to_bytes("ARriqkpHlC1Ia1Tk86EM_bqH_9a88Oh2zMYF3fUUGJw")
    y_value = base64url_to_bytes("wTYd3CEiwTk1n-lFPdpZ51P4Z0EzlVNXLvJMY-k55pQ")
    
    x, y = int.from_bytes(x_value, byteorder='big'), int.from_bytes(y_value, byteorder='big')
    
    point = ecdsa.ellipticcurve.Point(ecdsa.SECP256k1.curve, x, y)
    print(point) # (499815257955367864408742079034045137860289053987516893620084695066060527772,87391995603390372321291482400901178939983763504428780242289494985570894079636)
    

    An invalid EC point would result in an error message. So it is most likely indeed a Base64url encoding.


    Converting your Base64url encoded x and y coordinates to a compressed or uncompressed key is easy. You don't even need the ecdsa library.

    Note that for secp256k1 x and y must each have a size of 32 bytes in compressed or uncompressed format. If they should have numerically a value, which is smaller, they are to be padded from the front with 0x00 values to 32 bytes. A possible conversion looks as follows:

    ...
    x_value = base64url_to_bytes("ARriqkpHlC1Ia1Tk86EM_bqH_9a88Oh2zMYF3fUUGJw")
    y_value = base64url_to_bytes("wTYd3CEiwTk1n-lFPdpZ51P4Z0EzlVNXLvJMY-k55pQ")
    
    # padding with leadng 0x00 values
    x_32 = (32 - len(x_value)) * b'\0' + x_value
    y_32 = (32 - len(y_value)) * b'\0' + y_value
    
    compressed = (b'\02' if y_32[31] % 2 == 0 else b'\03') + x_32
    uncompressed = b'\04' + x_32 + y_32
    
    print(compressed.hex())    # 02011ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189c
    print(uncompressed.hex())  # 04011ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189cc1361ddc2122c139359fe9453dda59e753f86741339553572ef24c63e939e694
    

    Note that your compress_point() does not take padding into account (which is however not relevant for your values, since x and y are 32 bytes), nor does it return the hex encoded value with a leading 0 if the leading byte is single-digit (so format(x, 'x') returns the hex encoded value as 63-digit 11ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189c).

    For completeness: If x and y are already integers, the conversion to padded bytes objects can be implemented simply as follows:

    ...
    x_32 = x.to_bytes(32, 'big')
    y_32 = y.to_bytes(32, 'big')
    

    To convert a compressed key to an uncompressed key, the y value must be reconstructed, which requires modular arrithmetic. For this purpose ecdsa can be used (which implements the necessary math under the hood):

    from ecdsa import VerifyingKey, SECP256k1
    vk = VerifyingKey.from_string(compressed, curve=SECP256k1)
    uncompressed = vk.to_string('uncompressed')
    
    print(uncompressed.hex()) # 04011ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189cc1361ddc2122c139359fe9453dda59e753f86741339553572ef24c63e939e694
    

    Likewise, of course, converting an uncompressed key to a compressed one is just as possible.

    Note that your decompress_point() applies ecdsa.ellipticcurve.Point() incorrectly. The constructor requires as 2nd and 3rd parameters x and y coordinates as integers (see 1st code snippet).


    For completeness: from_string() can also handle the raw format, i.e. the concatenation of the 32 bytes x and 32 bytes y coordinate:

    ...
    from ecdsa import VerifyingKey, SECP256k1
    raw = x_32 + y_32
    vk = VerifyingKey.from_string(raw, curve=SECP256k1)
    uncompressed = vk.to_string('uncompressed')
    
    print(uncompressed.hex()) # 04011ae2aa4a47942d486b54e4f3a10cfdba87ffd6bcf0e876ccc605ddf514189cc1361ddc2122c139359fe9453dda59e753f86741339553572ef24c63e939e694