概述
公私钥密码对(pk-sk)是密码学安全的基石,很多的应用都是基于pk-sk, 例如https网页,银行以及数字货币等。pk-sk,相对于传统的AES, DES以及国密的SM4来说, 是异步的。异步的含义是:从sk可以计算出pk,从pk却不能计算得出sk。此处的异步, 有单向之说。 一方持有sk, 其他各方持有pk。
异步的密码对主要用用在两大类的应用中:
- 在认证应用中:需要证明你知道私钥
- 在加密应用中:消息被通过公钥编码之后,只有持有私钥的人能够打开消息
本文介绍数字签名,我们将以椭圆曲线这种特殊类型的key为例做介绍。当然也有其他的异步的方案,例如基于素数乘积的RSA算法等。
在阅读本文之前,我们首先假定你已经阅读过我们的上一篇文章《椭圆曲线101》,没有阅读过的小伙伴,可以仔细的阅读下。
启程
本文是一个交互式的介绍,我们将会使用Python Code来Demo本文中的一些知识点,所以在继续本文之前,请先安装python的加密库, 我推荐使用:ECPy 。ECPy中含有多种曲线,以及基本的Point运算,非常有用。
警告 在生产环境中使用ECPy请仔细评估.
Schnorr 签名基础
公钥与私钥
我们先从创建一个公钥PK与私钥SK对开始。我们选择的曲线是常用的secp256k1, Bitcoin, Eth, Eos等主流的公链用的都是这个曲线。
在secp256k1中,其实也不仅仅是secp256k1, 几乎所有的椭圆曲线,sk是一个之间的一个整数,上节课告诉我们, 这个数字不能为0. 这个数字有多大呢?简单来说,就是整个宇宙中原子的个数。
回顾一下上节课:在椭圆曲线中,有一点叫做基点G, 这是椭圆曲线就从这个点开始做切线,所以基点也叫作起点。PK与sk的关系如下:
下面这段代码可以更好的增加小伙伴们的理解:
#-*- coding: UTF-8 -*-
from ecpy.curves import Curve, Point
from ecpy.keys import ECPrivateKey, ECPublicKey
from binascii import b2a_hex
curve = Curve.get_curve('secp256k1')
sk = ECPrivateKey(1, curve)
pk = sk.get_public_key()
pk_point = pk.W
pk_compact = curve.encode_point(pk_point, compressed=True)
pk_normal = curve.encode_point(pk_point, compressed=False)
print("pk1:",b2a_hex(bytes(pk_compact)))
print("pk2:",b2a_hex(bytes(pk_normal)))
print("encod:",b2a_hex(pk_point.x.to_bytes(32, 'big')))
#pk1: b'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179800'
#pk2: b'0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'
#encod: b'79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
从Pk1 与Pk2 的输出中我们可以看出:
- 公钥在就是EC中的一个点;
- 公钥压缩模式为仅包含x坐标的点;
- 公钥的编码为x坐标,32位的位宽,大端模式;
- 公钥首字节:02, 04 以及末尾字节的00都具有含义;
创建签名
schnorr 签名
如果选择随机的sk,EC数学运算中的乘法运算的逆运算(除法运算)基本上不可能,这也就是所谓的离散对数问题。 离散对数问题是很多密码学以及签名的基石。 有效的签名能够证明针对该消息,签名者拥有签名中所蕴含公钥的私钥, 换句话说签名者知道DLP的结果,其他人因为不能解决DLP问题,所有也无法得知私钥。
这种签名的计算方法如下:
- 生成一个随机数.
- 根据生成public key:.
- 将下列信息发给张三:public Key(P),需要签名的信息m,以及引入的随机数R(注意不是r)以及一个签名s。 我们通常意义上讲签名描述为<R,s>, 其中s就是签名。 在签名中不具名P,是因为P一般为事先publish的。
上述的方法还是太笼统,我们更详细一点看看签名是如何产生的, 公式最简单直接(苏格拉底曾说过语言会阻碍智慧以及创造:)):
Hash 函数选择一般是选择与私钥具有相同的域,例如如果我们使用seckp256k1, sk的取值范围为 , 接近于一个32byte 大整数。 Hash函数也要在这个范围内。我们一般选择hash函数为 hash256 或者是hash512。
签名的构造如下:
我们公开给验证者的是s。
验证着拿到签名之后,如何验证签名的有效性呢? 一般来说签名的有消息性的验证是指验证一个等式。我们需要构造一个等式,通过构造的等式可以推证上述公式是正确的:
所以验证者只需要验证如下公式成立即可:
我们发送给验证者的参数中,上述公式所有的参数都具备。
为什么需要随机数
上述公式中有随机数似乎是多余的。 我们可以将随机数省略吗?我们来试试看:
我们来看看这里面有什么问题, 从上面的公式求解私钥:
这个似乎不是很难,因为这是一个标准的向量除法,并非一个DLP()除法:所以没有随机数将会导致私钥泄露。
为什么有了随机数会起到保护私钥的作用呢?
由于我们不知道r,所以我们也无法求解k。 这也就告诉我们,在签名时,随机数一定要好好保存。 我们再进一步看这个问题:如果一个系统的随机数是可以预测的,例如:两个随机数之间的差是恒定的:
这样也会很容易的计算出私钥k。
from ecpy.curves import Curve, Point
from ecpy.keys import ECPrivateKey, ECPublicKey
from binascii import b2a_hex
import gmpy
import hashlib
curve = Curve.get_curve('secp256k1')
sk = ECPrivateKey(1, curve)
pk = sk.get_public_key()
pk_point = pk.W
pk_normal = curve.encode_point(pk_point, compressed=False)
print("pk1:",b2a_hex(sk.d.to_bytes(32, "big")))
print("pk1:",b2a_hex(bytes(pk_normal)))
# Genreate sig
e = int.from_bytes(hashlib.sha256(b"hello" + bytes(pk_normal)).digest(), "big")
s = (sk.d * e)%curve.order
# Veryfy
recovered_S = pk.W * e
S = ECPrivateKey(s, curve).get_public_key()
print ("Verify successed? ", S.W == recovered_S)
# Recover private Key
e_inv = gmpy.invert(e, curve.order)
hacked_sk = (e_inv * s) %curve.order
print("Hacked key extracted? ", hacked_sk == sk.d)
#pk1: b'0000000000000000000000000000000000000000000000000000000000000001'
#pk1: b'0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8'
#Verify successed? True
#Hacked key extracted? True
我们用到了一个新的库: gmpy, 这个也是gmp的python 版本。 python的int 是天生支持大整数的,但是python 缺少模运算的支持,特别是模运算逆元的支持。
秘钥交换(ECDH)
ECDH是DH(Diffie-Hellman)算法的EC版本。 与DH算法类似, ECDH解决的是通讯双方如何安全的交换秘钥,以及建立共享秘钥的问题。
一个古老的问题:通讯的双方在明文环境中如何能够安全通讯并且建立起一个共享秘钥对消息进行加密呢?
- 如果通讯的双方知道对方的公钥,可以使用非对称加密的方式,但是非对称加密比较繁琐,效率也不高
- ECDH方式。通讯双方通过协商来确定非对称秘钥,该方式通讯效率高,计算简单
在区块链中,ECDH被用到了很多方面,其中就包含大名鼎鼎的闪电网络通道的建立。
ECDH的原理
张三<>跟李四<>在明文环境下想要安全通讯, 他们两方已经有了对方的Pk, 他们可以通过如下方式进行秘钥交换:
这种方式交换秘钥不需要进行握手,可以直接进行数据通讯,这点在很多场景极具诱惑力。例如在IOT环境中, 握手会耗费大量资源。下面是Python实现这个过程的代码:
from ecpy.curves import Curve, Point
from ecpy.keys import ECPrivateKey, ECPublicKey
from binascii import b2a_hex
curve = Curve.get_curve('secp256k1')
k1 = ECPrivateKey(1, curve)
P1 = k1.get_public_key()
k2 = ECPrivateKey(2, curve)
P2 = k2.get_public_key()
#Caculate the shared key
shared1 = P2.W * k1.d
shared2 = P1.W * k2.d
print("shared1:", b2a_hex(bytes(curve.encode_point(shared1))))
print("shared1:", b2a_hex(bytes(curve.encode_point(shared2))))
print("Shared key is equal?", shared1 == shared2)
#shared1: b'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a'
#shared1: b'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a'
#Shared key is equal? True
Schnorr 签名
前面花了大量的篇幅介绍基于椭圆曲线的非对称签名,交换等信息,我们现在进入正题:Schnorr签名。
如果你留意加密社区的信息,Schnorr在最近两年可是一个热词:
- 比特币社区数次讨论引入schnorr签名, 目的是通过降低签名的大小,以及使用签名聚合减少块大小(同样是1M空间可以容纳更多的交易),从而挺高TPS;
- BCH抢先一步,在去年8月份已经完成了schnorr 的升级;
其实schnorr并不是一个新鲜玩意儿,他被认为是被证明过的基于随机签名的最简单的scheme。 之前没有大规模推广,Patent是非常大的障碍,不过patent(U.S. Patent 4,995,082)已经在2008年过期。除了Patent之外,还有什么让schnorr没有被广泛应用呢?
Schnorr特性
ECDSA已经足够好了, 我们为什么还要继续探索Schnorr?答案:schnorr神奇的线性特征。
椭圆曲线具有乘法结合律, 也就是说下列公式是成立的:
上述公式中, 为私钥, 为公钥。 上述公式是一种完美的线性特性,椭圆曲线在Mod 有限域的这种特性,是很多的Crypto机制的基础。
Schnorr也具有很好的线性特性, 这点与椭圆曲线高度相同,也就是说,schnorr承袭了椭圆曲线的线性特性,所以在schnorr上可以派生出很多很好玩的东西,下面是一个列表:
- 签名聚合
- 原子交换
- 无脚本脚本(scriptless script)
与生俱来的签名聚合
我们来看一个2-2 的mulsig的签名:
现实生活中,联合签名(cosign)比较常见,而且签名的多方往往是独立的,也就是互相不信任。假设张三与李四想要联合签名某件事情,他们两个又是互相不信任的。为了达到这个目的,他们必须独立对数据进行签名,而且只有他们两个都提供对签名的验证的时候后,签名才有效。
跟前面一样,假设我们使用分别表示私钥与公钥, 聚合签名是如何实现的呢? 首先张三与李四通过0轮交互,获取对方的公钥(这一步其实是可以省略的,一般来说双方都会互相公布公钥),第一轮交互,双方公布一个Nance值其实就是一个随机数,双方利用对方的随机数以及公钥计算消息的hash值:
双方的hash值的计算必须一致, 针对一致的Hash值, 我们来看一下双方的签名:
非常神奇,两个签名合一合并为一个签名,公式中的关键是双方使用同一个e, hash值的计算得一样。
我们最终的目的是让 hash值一样, 我们可以直接使用吗? 使用这种算法似乎并不影响上述公式的正确性,带来的优势是比上述公式还简单。
这个问题的答案其实与聚合签名无关,而是与schnorr的简单性有关:
如果我们对同一个数据,进行两次签名: , 由于, 最终会得出:, 这会导致我们能够预测r的值,从而导致泄漏r而计算出k。
难道上述方案就是一个完美的方案了吗?
秘钥消除攻击
我们似乎有了一个简单(线性)完美(已经解决了上面提出的推测r的攻击)的方案。是这样了?我们来看一个例子:签名聚合本来的目的是为了确认所有人都已经签名,例如在本例中,需要张三,李四都签名,才能获取到聚合签名。如果有方法,可以让签名只需要一个人签名, 把其他的人旁路掉。也就是说,张三想要把李四旁路掉,他可以通过如下的方式进行:
- 李四公布自己的公钥,Nance值
- 张三公布自己的公钥为, Nance值为:
- 其他人在验证签名时,聚合签名为:, 其中
看到了, 大家验证的实际上张三的签名,张三使用Key Cancelation 把李四的签名给消除了!
这种签名会带来什么样的后果呢?设想一个场景,例如银行的共有账户,需要向王五转账1000元, 需要张三, 李四共同签名,交易才有效。张三, 李四按照上述步骤公布了公钥以及Nance值, 李四签完名之后,交易交给张三, 张三把转账的金额修改为10000元, 并且使用 签名, 这时候,银行的验证者验证 聚合签名,实际上验证的张三的签名。
李四被旁路了!有没有方法解决这个问题呢?有的
聚合签名的优化方案
在上述的案例中,李四被旁路了,这事儿是张三做的。 有没有方法能够阻止张三这样做呢?其实办法很简单:张三之言能够证明的私钥就OK了。但是通过签名的方式证明拥有私钥,在交互的过程上增加了一轮, 有人想出了更好的方法, 也就是我们将要讲的Musig。
Musig
Musig 是最近公布的一个基于schnorr 的方案, 这个方案具有schnorr的优美的线性特性,同时能够抗Key Cancelation攻击。 Musig 的签名的流程如下:
- 与之前的setup一样,所有参与聚合签名的人需要公布自己的公钥
- 每个签名人计算共享的公钥X:
- 每个签名人公布自己的随机数的公钥, 并且计算共享公钥:
- 计算消息的hash值:
- 签名的公式如下:
聚合签名的验证:
Musig 能够抗击 秘钥消除攻击吗?
由于在musig 的签名中引入了 , 这个签名是跟hash 函数紧密相关, 这一部分不能Cancel掉。 所以musig 可以抗击 musig的攻击。
Replay 攻击
不懂安全的人来写安全的代码,最常犯的错误是随机数的使用。随机数对于安全来说至关重要。一般来说随机数在安全中是为了保护私钥不被泄露,在schnorr签名中,随机数也同样重要。 所以写安全代码,一定要使用密码学安全的随机数生成器(CSPRNG)。
设想如下场景,一个攻击者开始要求某个签名者给一个消息签名,当签名完成之后,攻击者通过某种手段(常用的是使得程序崩溃)让重新回到给签名的时刻,由于是musig,所以此时不能改变随机数等参数,不然便不能参与本次联合签名。此时攻击者发送给一个行的消息, 会使用原来的参数计算出新的签名, 此时攻击者拿到了如下信息:
攻击者可以通过如下的公式计算出签名者的私钥:
所以对于musig的签名,一定要非常小心的核心自己生成的随机数,不能使用两次!
这篇文章写了(翻译)了半个月, 大部分都是在路上完成的,其中错误可能会很多,希望读者指正。