编出个区块链:数据结构的序列化

2022-03-28 15:45:03 浏览数 (1)

有过区块链开发相关工作经验的同学知道,要开发智能合约的应用,你首先需要通过geth同步以太坊主网,这意味着你需要从其他节点下载很多数据。另外在使用区块链技术时,例如支付,接收数字货币时,“钱包”应用需要发送一系列数据给对方,当我们需要通过网络收发数据时就需要对数据进行序列化。

前面我们了解了很多数据结构,例如有限群,椭圆曲线,公钥,私钥等,相关数据在应用时都需要通过网络进行数据传输,因此相关的数据结构需要进行序列化。对于椭圆曲线上一个点,在序列化时通常采用一种叫做非压缩SEC(standard for efficient cryptography)的数据格式,其步骤为: 1,在开头添加0x04作为标志 2,放置点的x坐标数值,它有32字节 3,放置点的y坐标数值,他有32字节。

相应的序列化代码如下:

代码语言:javascript复制
    def sec(self):
        '''
        sec压缩在开头写入04,然后跟着32字节的x坐标值,最后跟着32字节的y坐标值
        '''
        return b'x04'   self.x.num.to_bytes(32, 'big')   self.y.num.to_bytes(32, 'big')

既然有非压缩那就有对应的压缩形态SEC,由于椭圆曲线关于x轴对称,因此给定一个x坐标,它最多对应两个y坐标,这两个y互为相反数。但由于我们作用在椭圆曲线上的点都是有限群里面的元素,对于含有p个元素的群而言(注意到p是素数),如果y是群里面的元素,那么”-y”就是p-y,如果y是偶数,那么p-y就是奇数,反之如果y是奇数,那么p-y就是偶数。利用这个特点我们可以压缩y坐标对应的内容。

于是给定椭圆曲线上一点(x,y),压缩形态SEC生成的步骤为: 1,如果y是奇数,那么以0x03开头,如果是偶数则以0x02开头 2,添加32字节的x坐标值 于是相应实现代码就是:

代码语言:javascript复制
 def sec(self, compressed = True):
        if compressed:
            if self.y.num % 2 == 0:
                return b'x02'   self.x.num.to_bytes(32, 'big')
            else:
                return b'x03'   self.x.num.to_bytes(32, 'big')
        '''
        sec压缩在开头写入04,然后跟着32字节的x坐标值,最后跟着32字节的y坐标值
        '''
        return b'x04'   self.x.num.to_bytes(32, 'big')   self.y.num.to_bytes(32, 'big')

相对于非压缩形态,压缩形态的序列化就节省了32字节。既然节省掉y,那么接收方收到数据后就需要还原它,还记得椭圆曲线的格式为 y ^ 2 = x^3 a * x b,我们知道了x的值,那意味着知道了y平方的值,现在我们需要计算y的值。

假设w, v为有限群的元素,并且有w^2 = v,其中群元素总共有p个。现在我们知道v,我们需要计算w。如果p % 4 = 3,那么我们有好的算法能快速计算w。由于p % 4 = 3, 于是有(p 1) % 4 = 0。也就是(p 1) 能整除4,也就是(p 1) / 4 是一个整数。根据费马小定理 w ^ (p-1) % p = 1, 于是有w ^ 2 = w ^ 2 1 = w ^ 2 (W ^ (p-1) ) = w ^ (p 1),由于p是奇数,因此(p 1)就能整除2,也就是(p 1)/2 是一个整数,于是就有 w = w ^ ((p 1)/2)。

上面我们提到(p 1)/4是一个整数,于是就有 w = w ^ ((p 1)/2) = w ^ (2 * (p 1)/2) = (w ^ 2) ^ ((p 1)/4) = v ^ ((p 1)/4),于是如果 w ^ 2 = v, 并且有 p % 4 = 3, 那么 w = v ^ ((p 1)/4)。对于比特币使用的椭圆曲线,p值恰好能满足p % 4 = 3。这样一来,我们就将开方运算转为求指数运算,由此有限群元素开方运算的实现代码为:

代码语言:javascript复制
P = 2 ** 256 - 2 ** 32 - 977
class BitcoinFieldElement(FieldElemet):  #S256Field
    def __init__(self, num, prime = None):
        super().__init__(num, P)
    def __repr__(self):
        return "{:x}".format(self.num).zfill(64)  # 填满64个数字

    def sqrt(self):
        return self ** ((P 1) // 4)

我们看看如何解析压缩形态的SEC格式数据,其实现方法为:

代码语言:javascript复制
    def parse(self, sec_bin): #解析sec压缩数据
        if sec_bin[0] == 4: #非压缩sec格式
            x = int.from_bytes(sec_bin[1:33], 'big')
            y = int.from_bytes(sec_bin[33:65], 'big')
            return BitcoinEllipticPoint(x = x, y = y)

        #在压缩sec的格式下,先获取x,然后计算y的平方,最后使用开方算法获得y的值
        is_even = sec_bin[0] == 2
        x = BitcoinEllipticPoint(int.from_bytes(sec_bin[1:], 'big'))
        #y ^ 2 = x ^3   7
        alpha = x ** 3   BitcoinEllipticPoint(B)
        beta = alpha.sqrt()
        '''
        在实数域中,y会对应一正一负两个值,在有限域中同理,如果y和(p-y)互为正和负.
        也就是在有限群中,如果y满足椭圆方程,那么p-y同样满足椭圆方程。由于比特币对应有限群中元素个数P为素数,
        因此如果y 是偶数,那么p-y就是奇数,如果y是奇数,那么p-y是偶数。于是在压缩SEC格式中,如果开头标志位为0x02,
        那么如果计算出来的y是奇数,那么就要采用P-y
        '''
        if beta.num % 2 == 0: #y是偶数,P-y是奇数
            even_beta = beta
            odd_beta = BitcoinFieldElement(P - beta.num)
        else: #y是奇数,P-y是偶数
            even_beta = BitcoinFieldElement(P - beta.num)
            odd_beta = beta

        if is_even:
            return BitcoinEllipticPoint(x, even_beta)
        else:
            return BitcoinEllipticPoint(x, odd_beta)

接下来,我们给几个私钥e,通过e * G 获得对应公钥,然后看看公钥对应的SEC压缩格式数据,代码如下:

代码语言:javascript复制
'''
给定如下私钥,求它公钥的压缩sec个数:
5001, 2019 ^ 5, 0xdeadbeef54321
'''
priv = PrivateKey(5001)
print(priv.point.sec(True))

priv = PrivateKey(2019 ** 5)
print(priv.point.sec(True))

priv = PrivateKey(0xdeadbeef54321)
print(priv.point.sec(True))

上面代码运行后结果如下:

代码语言:javascript复制
0357a4f368868a8a6d572991e484e664810ff14c05c0fa023275251151fe0e53d1
02933ec2d2b111b92737ec12f1c5d20f3233a0ad21cd8b36d0bca7a0cfa5cb8701
0296be5b1292f6c856b3c5654e886fc13511462059089cdf9c479623bfcbe77690

另外还需要序列化的结构就是签名。他有两个数值需要处理,分别是s和r,这两个值没有逻辑上的关联,因此不能像上面那样压缩。在区块链中用于序列化签名的格式叫DER(Distinguished Encoding sinatures)。DER格式如下: 1,以0x30字节开头 2,添加s和r的长度之和,通常情况下是(0x44和0x45)。 3,添加0x02作为分隔符 4,添加r的长度(一字节)将r转换为大端字节形式,如果它开头的字节>=0x80,那么先添加一个0x00然后添加r的内容, 5,添加s的长度(1字节),将s转为大端格式,如果它首字节>=0x80那么先添加一个0x00,后面才跟着s的内容。 由于r是256位数值,因此它最多32字节,如果它首字节>=0x80,那么我们需要在其前面添加0x00,因此r最多有33字节。s同理可以推断。我们看看代码实现:

代码语言:javascript复制
class Signature:
    def __init__(self, r, s):
        self.r = r
        self.s = s

    def __repr__(self):
        return f"Signature({hex(self.r)}, {hex(self.s)})"

    def der(self):
        #现将r转换为大端格式
        rbin = self.r.to_bytes(32, byteorder='big')
        #去掉开始的0x00内容
        rbin = rbin.lstrip(b'x00')
        if rbin[0] & 0x80: # 如果头字节>=0x80,在前头添加0x00
            rbin = 'x00'   rbin
        #以0x2开头,接着是r的长度,最后是r的内容
        result = bytes([2, len(rbin)])   rbin

        sbin = self.s.to_bytes(32, byteorder='big')
        sbin.lstrip(b'x00')
        if sbin[0] & 0x80:
            sbin = b'0x00'   sbin
        result  = bytes([2, len(sbin)])   sbin
        '''
        以0x30开头,跟着是r和s的总长度,然后是r和s的编码
        '''
        print(f"der total bytes:{len(result)}")
        return bytes([0x30, len(result)])   result

我们运行上面编码试试:

代码语言:javascript复制
sig = Signature(0x37206a0610995c58074999cb9767b87af4c4978db68c06e8e6e81d282047a7c6,
                0x8ca63759c1157ebeaec0d03cecca119fc9a75bf8e6d0fa65c841c8e2738cdaec)
print(f"signature der format:{sig.der().hex()}")

代码运行后结果如下:

代码语言:javascript复制
signature der format:3048022037206a0610995c58074999cb9767b87af4c4978db68c06e8e6e81d282047a7c60224307830308ca63759c1157ebeaec0d03cecca119fc9a75bf8e6d0fa65c841c8e2738cdaec

由于目前所有编码都是二进制格式,这种格式有个问题就是不利于人的阅读理解。因此比特币后来采用base58来将二进制数据再次进行编码,之所以不用base64是因为后者有限字符容易令人混淆,例如数字0和字母O,小写的l和大写的I,于是使用base58能避免这些问题,我们看看base58编码实现:

代码语言:javascript复制
'''
base58 的编码字符没有小写的l和大写的I,以及大写的字母O
'''
BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
def encode_base58(s):
    '''
    先统计要编码的数据以多少个0开头
    '''
    count = 0
    for c in s:
        if c == 0:
            count  = 1
        else:
            break

    num = int.from_bytes(s, 'big')
    prefix = '1' * count
    result = ''
    while num > 0:
        num, mod = divmod(num, 58)
        result = BASE58_ALPHABET[mod]   result

    return prefix   result

我们试试上面代码:

代码语言:javascript复制
'''
测试base58编码
'''
encode = encode_base58(bytes.fromhex('7c076ff316692a3d7eb3c3bb0f8b1488cf72e1afcd929e29307032997a838a3d'))
print(f"base58 encode:{encode}")

上面代码运行后得到的编码内容为:

代码语言:javascript复制
9MA8fRQrT4u8Zj8ZRd6MAiiyaxb2Y1CMpvVkHQu5hVM6

base58编码本身有很多毛病,在市场上已经很少用,只是它某些特定特点正好能在区块链或比特币上用到。在以太坊或比特币应用上,数字货币在转账时需要有对应的接收地址,而这个地址的编码就使用到了base58,我们看看具体流程: 1,如果地址来自主网,那么以0x00开头,如果来自测试网络则以0x6f开头。 2,拿到SEC编码(可以是压缩也可以非压缩),对其进行sha256哈希,然后把结果再次进行ripemd160哈希,这个过程叫hash160操作。 3,把第一和第二步所得结果前后连接起来 4,将第3步结果进行sha256哈希,取开头4个字节 5,将第3和第4步所得结果结合起来,进行base58编码 第5步所得结果也叫校验和,假设我们已经有了第3步的结果,然后看看如何实现第4,5两步,代码如下:

代码语言:javascript复制
'''
实现地址编码的第4,5两步
'''
def encode_base58_checksum(b):
    return encode_base58(b   hashlib.sha256(b).digest()[:4])

上面提到的hash160操作其实可以直接调用hashlib接口来实现:

代码语言:javascript复制
def hash160(s):
    return hashlib.new('ripemd160', hashlib.sha256(s).digest()).digest()
'''
在比特币应用中,哈希256都会连续执行两次以增强安全性
'''
def hash256(s):
    return hashlib.sha256(hashlib.sha256(s).digest()).digest()

我们把这些步骤正好到椭圆曲线的点中:

代码语言:javascript复制
class BitcoinEllipticPoint(EllipticPoint):
    ....
    def hash160(self, compressed=True):
        return hash160(self.sec(compressed))

    def address(self, compressed=True, testnet=False):
        h160 = self.hash160(compressed)
        if testnet:
            prefix = b'x6f'
        else:
            prefix = b'x00'
        return encode_base58_checksum(prefix   h160)

我们运行上面实现的逻辑看看:

代码语言:javascript复制
'''
测试椭圆曲线点的地址编码
'''
priv = PrivateKey(5002)
print(f"address for uncompressed SEC on testnet:{priv.point.address(compressed = False, testnet = True)}")

代码运行后结果如下:

代码语言:javascript复制
address for uncompressed SEC on testnet:mmTPbXQFxboEtNRkwfh6K51jvdtHLxGeMA

还有一个数据结构需要序列化,那就是私钥。这个东西由于非常敏感,一旦你的私钥丢失,你所有的货币资产就会被别人窃取,因此我们通常不会在网络上传输私钥,但极个别时刻需要这么做,因此我们也需要对私钥进行序列化,它对应的格式叫WIF(Wallet Import Format),它的序列化步骤如下: 1,如果是主网私钥,以0x80开头,如果是测试网以0xef开头 2,将私钥转换为大端字节数组进行编码 3,如果公钥使用压缩SEC格式,那么在末尾添加0x01 4,将1,2,3三个步骤所得结果结合起来 5,将第四步进行sha256(也就是连续两轮256哈希)运算,取结果的前4个字节 6,将步骤4和5结合,使用base58进行编码 我们看看相应的代码实现:

代码语言:javascript复制
class PrivateKey:
    ....
        def wif(self, compressed = True, testnet = False):
        #先将秘钥转换为大端字节序
        secret_bytes = self.secret.to_bytes(32, 'big')
        if testnet: #根据主网还是测试网添加前缀
            prefix = b'xef'
        else:
            prefix = b'x80'

        if compressed: # 根据压缩形态添加后缀
            suffix = b'x01'
        else:
            suffix = b'1'

        return encode_base58_checksum(prefix   secret_bytes   suffix)

我们实验一下秘钥的序列化功能:

代码语言:javascript复制
''
检验秘钥的序列化功能
'''
priv = PrivateKey(5003)
print(f"secret key wif:{priv.wif(True, True)}")

上面代码运行后结果为:

代码语言:javascript复制
secret key wif:cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN8rFTv2sfUK

比特币应用的代码很混乱,中本聪混淆着使用大端字节序和小端字节序,这是创始人的特权,就像鲁迅写错别字就成为了“通假字”,因此我们还需要了解小端编码的实现:

代码语言:javascript复制
'''
我们使用一段文字进行256哈希形成秘钥,然后设置秘钥的地址格式
'''
secret_text = "this is my secret key"
secret = little_endian_to_int(hash256(secret_text.encode('utf-8')))
priv = PrivateKey(secret)
print(f"secret key address:{priv.point.address(testnet=True)}")

上面代码运行后所得结果为:

代码语言:javascript复制
secret key address:mtg8uRgVQHjPiX15mmt7FVZeqSUbkUn3h3

我们这节内容很多,好在逻辑并不复杂,主要就是比较繁琐。代码下载路径:https://github.com/wycl16514/blockchain-serialization.git

0 人点赞