概述
笔者前一阵子在博客中,讨论了基于chacha20结合非对称加密密钥协商的加密算法实现。那个讨论,是完全基于nodejs crypto模块来开展的。笔者也注意到,crypto内置了chacha20加密算法的内置支持,并且使用的方式也是标准化的,几乎和AES完全相同,所以代码的实现、编写和移植就非常方便。
但如果将这个问题,放在浏览器环境中,情况可能就有很大差异,因为到目前为止,webcrpto的subtle是不支持chacha算法的,对AES的支持也比较有限。所以,如果要在浏览中开展相关的应用,还是需要一个纯JS实现来支持chacha算法,这就是本文所需要探讨的主题。
所以本文探讨的一个核心的内容,就是在浏览器环境中的chacha算法的JS实现。但除此之外,笔者还发现,有一些相关的问题,包括对流加密算法的认识,和算法程序模块的架构,也是非常有意思,值得分享和讨论的,甚至有可能这些内容比算法本身更加有价值。
流式加密 Stream Encrypt
笔者先想从chacha20这个算法所属的流式体系说起。笔者认为,相比分组加密如AES而言,流式加密的架构和处理更加简单巧妙。当然,首先需要理解的是,以AES为代表的分组加密(也称为块加密)和以Chacha为代表的流式加密,它们都属于对称加密算法,就是加密和解密,都使用相同的密钥。只不过是使用密钥的方式,和处理原文的方式有所不同而已。
以AES为代表分组加密的设计看起来更加直观和容易理解。笔者曾经仔细的研究过AES的实现方式。它先将加密的原文,分解成为一个个的"块"(block,AES中是4x4的Byte),然后按照加密规则,对这个块进行各种变换,其中也包括了和密码变换进行的复合操作,最后,还要按照算法规则,将这些结果块通过某种方式连接起来(加密模式),获得最终的加密结果。解密基本上是加密过程的逆操作,相当于将加密的结果,一步步回退来还原。由于这些操作实现的特点,同时为了保证安全性,需要考虑很多因素,比如初始向量、密钥长度、变换操作的顺序和轮次、加密模式、块信息的补码等等。这些因素累加起来,其实是比较复杂的。
而流式加密可能更加巧妙和简单。它不对原文进行处理,而是基于一个初始的随机信息和密钥,按照一个计数器,衍生出一系列密钥,然后对原始信息上每一个位,都使用对应位置上的密钥的位,进行异或操作,就是加密,再进行一次异或操作,就可以还原原始位,就是解密的操作了。
当然,为了实现的方便,密钥的流的生成,实际上是基于"块"的方式生成的,但显然,不需要考虑原始信息分组的问题,也就不用考虑补位和还原的问题,显然就更简单和直观。
此外,由于块密钥,是基于原始密钥、初始化向量和块计数器来生成的,所以可以"按需要"生成,也就是说,如果原始信息是逐渐生成的,在不知道原始信息的大小和结束点的情况下,也不影响块密钥流的生成,就达成了所谓的"流加密"。
显然,如果考虑实时处理的情况,流加密可以只处理当前需要处理的信息,在进行流处理的时候,可以大幅度降低内存资源占用的情况。比如,要加密一个文件,流加密模式可以完全不需要读取整个文件的内容,只需要对当前输入流的内容进行处理就可以了,理论上只占用一个流加密块需要的内存,就可以完成工作。
流加密还可以近似实时的对在网络上传输的内容进行分组的加密和解密,特别适合于网络应用特别是直播、音视频传输这种"信息流"化的应用应用场景,而且不需要特别担心计算量和资源占用的情况。
chacha20-poly1305
和分组加密算法一样,流式加密的算法和实现也有很多,经典的算法有RC4等。但在互联网时代,比较现代化、主流和应用比较广泛的算法是Chacha20-Poly1305。
关于这个算法本身而言,笔者已经在不同的场合和博客中提到过,但通常都是从应用的角度出发,并没有更深入的分享和研究。但如果需要一个纯JS的实现,就不可避免的涉及到其基本架构和原理,需要更深层次的认知。
算法的全称是chacha20-poly1305(以下称为CP1305)。所以它实际上是由两个部分构成的。chacha20和poly1305。它们分别用于信息加解密,和对信息进行认证,从而构成了完整的AEAD(Authenticated Encryption With Associated Data,附带相关信息的认证加密)体系。这个体系相对普通的对称加密而言更加完备,可以保证加解密过程的安全,并且支持附带一些非加密的信息(提供业务灵活性)。
chacha20的算法来自shasha,它是一种流加密的算法,并进行了一些优化和改进。这里的20是在这个算法中,有一种操作,需要执行20个轮次,这个我们会在后面的代码分析中清晰的看到。
poly1305是一种验证码算法,它和加密算法结合起来使用,可以保证加解密过程中,信息是完整可靠的。这里的1305,其实是指算法中会用到一个素数: 2^130-5。(这里谁有兴趣证明一下,它是一个素数?)
虽然名气没有AES那么大,但实际上CP1305并不是什么稀有非主流的算法,它已经被非常广泛的应用在各种各样的系统和场景当中。比如IPSec、SSH、TLS1.2/1.3、DTLS1.2、WireGuard、S/MIME4.0、OpenSSL、libsodium、Borg、Bcachefs等等,涉及基础网络安全、通信、VPN、内容加密、文件系统、数据备份等多种场景和技术。
下面是一张来自wiki的CP1305算法的原理和流程图,可以帮助我们对照和分析后面的实现代码:
这里可以展示一些最基础的结构和流程包括:
- 算法的输入,包括关联数据(AD)、密钥(K)、随机信息(Nonce)、和明文Plaintext
- 大的流程分为chacha20和ploy1305两个阶段
- chacha20,使用Key、Nonce和一个计数器,块密钥CC_block
- 结合计数器迭代,多个块密钥,就可以生成一个密钥流Keystream
- 使用密钥流,来对原文进行位级别的加密(异或计算),得到密文C
- 认证方面,是另外一套流程
- 认证的信息,包括了AD、密文C和它们的长度信息
- 认证所使用的密钥,来自Key、Nonce和Counter0,其实就是chacha的第一个密钥块
- 然后使用Ploy1305算法(后详),会得到一个认证标签T
- 至此,加密和认证的过程就已经完成
- 在解密的时候,几乎是相同的过程和输入信息,由于异或计算的特性,再进行一遍,就可以由密文得到原文
- 可以获得相同的,获得解密的认证信息T1
- 和原文认证信息进行比较,就可以判断认证过程的完整性了
后续我们会进一步结合这个流程图和实现的代码,来对照讲解相关的部分和内容。
实现和示例
这个算法的JS原生实现,和相关的应用和测试代码,可以参考下面的示例:
这个示例应该能够说明一个问题,其实一个主流的安全对称加密和解密算法,其实并不是特别复杂,当然密码算法的解构设计确实非常重要,但算法的安全性的核心,应当是由密钥提供的。AES算法也是类似的情况,笔者有专门的博客进行了分析。
代码中,其实演示了一个比较完整的流程,进行了混合的nodejs和浏览器环境的编程。先使用nodejs crypto模块和内置的chacha20-poly1305算法,进行加密;然后在后续分别使用crypto模块和纯JS实现进行解密验证,从而证明了这个实现,在浏览器端和nodejs后端之间的互操作性,和JS实现的标准性。
这段代码也是笔者从网络上的一些实现代码中迁移而来,笔者基于自身的需求和习惯,进行了相关的调整和改进,比如将其封装成为一个类,重写了一些工具函数等等。核心代码也就是不到200行,下面笔者尝试进行简单的梳理和分析。
配置信息和参数
首先就是基于Chacha20-Poly1305算法标准流程,设置的一些算法参数和初始数据,其中比较重要的信息包括:
- 主变换计算轮次:10轮,但每轮中其实有两种计算
- 初始化向量长度: 12字节(标准)
- authTag长度: 16字节(标准)
- 块大小:64字节
- 初始块: [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
工具函数
这里的工具函数,包括:
- randomArray 随机信息发生器
- b64Encode: base64编码器,将Uint8Array编码成为Base64字符串
- b64Decode: base64解码器,将Base64字符串解码为Uint8Array
- lenBuf: 内容长度信息编码
- padBuf: 内容信息补位
- rotate: 循环左移,一种循环位移操作
Chacha核心变换
ChaCha的核心变换,其实就是一个函数:quarterRound(四分之一轮,QR)(下图)。
这个函数的设计缘由和设计这里就无法在深入探讨了,它就是这样设计的,就是一个数据的变换方式。这个变换里面,只用到了ADD-Rotate-XOR(ARX)三种操作,可以在计算机中非常高效的运行。对照下面的JS代码,可以更直观的了解是如何实现图中操作流程的:
css
x[a] = (x[a] + x[b]) >>> 0; x[d] = this.rotate(x[d] ^ x[a], 16);
x[c] = (x[c] + x[d]) >>> 0; x[b] = this.rotate(x[b] ^ x[c], 12);
x[a] = (x[a] + x[b]) >>> 0; x[d] = this.rotate(x[d] ^ x[a], 8);
x[c] = (x[c] + x[d]) >>> 0; x[b] = this.rotate(x[b] ^ x[c], 7);
这里,x就是密钥块的数组,abcd是数组元素的位置(索引)。在块计算中,每次都有4个块元素按照规则参与变换操作,设及整个块的1/4,所以这个变换就叫QuarterRound。
块计算 chaBlock
chaBlock函数其实就是基于原始的密钥和一个计数器,来生成新的key的块的过程。
每一个块的初始结构,都是类似的(下图),差异主要就在于当前要加密的块所使用的计数器。
也就是下面这个数组:
这个结构,也很好的解释了,为什么Chacha20所使用的初始化向量长度为12字节。因为在每个初始块的16个元素里面,有4个是常量,8个是密钥,1个计数器,就只有3个留给了随机初始信息。
在每个块里面,会做10轮变换;每轮变换中,包括两个不同类型的变换方式;每种变换方式都包含了1/4的块。对于每个块元素而言,总共就是20轮。也就是chacha20的由来。
为什么要做这两种变换呢? 看一下这些变换的元素的位置,我们就应该可以理解了(下图和代码)。 其实就是先对每一列做一次变换,然后对对角元素做一次变换。(如果是强迫症患者,难道不应该再加一个行元素变换和反对角元素变换吗?)
js
let wkState = new Uint32Array(state);
for (let i = 0; i < this.CHA_ROUND; i++) {
this.quarterRound(wkState, 0, 4, 8, 12);
this.quarterRound(wkState, 1, 5, 9, 13);
this.quarterRound(wkState, 2, 6, 10, 14);
this.quarterRound(wkState, 3, 7, 11, 15);
this.quarterRound(wkState, 0, 5, 10, 15);
this.quarterRound(wkState, 1, 6, 11, 12);
this.quarterRound(wkState, 2, 7, 8, 13);
this.quarterRound(wkState, 3, 4, 9, 14);
}
块计算的基本过程包括三个步骤,首先是一个初始块,然后基于初始块,和10轮2种QR变换,生成一个操作块,然后和初始块进行一个矩阵相加,就得到最后的块密钥,可以用于后续加密处理了。
chacha有一种不常见的分支,就是chacha12,那就是应该是计算6轮了。所以理论上我们也可以使用不同的轮次,发明自己独有的算法实现吗?
还有可以看到,这个结构,块密钥的计算,其实是可以做并行处理的,因为每个块,都不依赖其他的块。这样可以方便的提高加解密处理的性能。
加解密编码 chaEncode
加解密编码,就是基于前面提到的密钥块的生成方式,从输入的Key和Nonce,生成一个和原文相同长度的KeyStream(下图)。然后和原文的每个对应的位,都作一个异或操作,就得到了加密后的信息:
上图中的Keystream是基于Key、Nonce和一个计数器来产生的(下图)。这个函数,主要是用来使用轮询的方式,来构造块密钥和调用块计算的。需要注意,加解密编码的调用,是从1开始调用块计算的(从0开始的是留给poly认证计算的)。 :
计算出块密钥之后,就简单的和原文进行异或计算,就进行了加密。解密就是同样的方式,获取同样的块密钥,在对密文进行一次异或计算,就可以得到加密前的原文。
验证函数 poly1305Tag
Poly1305Tag是和chacha20配套的认证计算方法。但从代码中,我们可以看到,在逻辑上它是独立的。因为Poly1305本质上,就是一类Hash函数和MAC(消息验证码),只不过它在和ChaCha20配合使用的时候,需要配套的认证内容构造方式,和密钥生成方式(如图中所示)而已。除了Chacha之外,笔者在网络上还看到和AES结合使用的方式。
Ploy1305在使用的时候,会对包括附加信息(AD)、AD的补码、加密内容(C)、C的补码、AD的长度信息、C的长度信息,来作为认证的原始内容,然后使用PolyKey进行相关的计算,得到的结果是一个128位的二进制整数,也可以表示成为一个16个字节的数组,就是一个所谓的认证标签(Tag)。这个标签,可以用于代指这个加密的原始信息。解密后可以通过同样的方式来计算一个新的Tag,和原始Tag相比较,来确认加密信息和过程的完整性。
就单一的计算过程而言,Poly1305Tag的计算其实是比chacha要复杂的,主要是涉及大整数操作,所幸的是,这个计算只需要在加密后最后操作一遍,来获得密文的Tag。大致的过程如下:
1 构造需要验证的数据macData, ...adata, encryted, adata.length, encrypte.length, 都要做补位
2 获取认证key(PolyKey),来自chacha计数器为0时的密钥块的前32个字节
3 将PloyKey分成两个部分,r和s
4 对r进行简单的变换,然后倒置,并转换成为大整数rVal
5 确定tag初始值为0n,记为acc
6 按照块的方式,遍历macData数组,每次取16个字节,然后再后面加一个值为1的字节,倒置这个字节数组,并转换为大整数,记为n
7 求acc和n的和,并和rVal相乘,并求余(2^130-5),作为当前的acc值
8 进入下一个macData块,直到遍历完成
9 将s进行倒置,并转换为大整数sVal
9 将acc和sVal相加,并求余(2^128)
10 结果是一个128位的整数,通过移位操作转换为一个16字节的数组,就是Tag的结果
11 可选和传入的Tag进行比较匹配,实际上是数组遍历比较
验证的时候,将这个过程重复一遍,然后比较原始Tag和计算Tag是否匹配,来确认加密和解密过程中,所有的信息都是否匹配,来保证加解密操作过程中,信息的完整性。
从这个实现中,可以看到,由于Poly1305的操作,涉及很多大整数操作,而JS本身不能直接使用大整数操作,只能先构造大整数的hex字符串,然后利用BigInt构造方法来产生大整数对象进行计算。
简单起见,文中的实现Tag生成和Tag检查,使用了同一个函数,使用是否传入tag值,来确定是生成还是检查模式。
入口函数 encrypt, decrypt
前面所有的配置信息、工具和核心变换,都是封装在内部的函数,共内部操作使用的。对于外部调用,实际上就只有两个入口函数,分别是加密函数和解密函数。
- encrypt: 加密程序
加密函数的参数包括:需要加密的信息(明文字符串),密钥,可选的初始化向量和附加信息。
明文支持普通的原始UTF8文本字符串和编码后的字节数组。如果是字符串,则使用TextEncoder将其编码成为字节数组进行后续的处理。
密钥是32个字节的字节数组,需要在调用时在外部进行编码(由于可能来自外面的密钥协商或者对象)。
初始化向量,可选由外部提供,如果没有,则会在内部随机生成一个。形式为12字节的随机字节数组。
附加信息,同样为UTF8字符串,函数会将其编码成为加密所需字节数组。这个信息,对于外部程序是明文。本身不参与加密和解密,但参与认证校验。
数据准备完成之后,将调用chaEncode函数,进入加密流程。
加密的原始结果,是一个和原始明文等长的字节数组。
程序基于这个加密结果,初始向量iv,附加信息和密钥,计算认证信息authTag。为一个16字节的字节数组。
程序最后将加密结果、随机向量iv、附加信息和认证标签整体封装成为一个Uint8字节数组,最后转换成为一个base64字符串,作为最终的加密结果。这个加密信息,就可以在网络上传递或者存储了。
在信息封装的时候,因为考虑到附加信息是可变的,需要在其后面增加一个字节表示此信息的长度。因此当前算法的附加信息的长度限制为256字节,足够满足绝大多数的场景使用了。
由于iv的随机性,最终加密结果会表现出随机性。
- decrypt 解密程序
解密计算,基本上是加密计算的逆运算,但由于流加密算法的特性,和AES相比,操作更加简单直观。
解密计算的入参,就是加密结果的base64字符串。程序将它先转换成为Uint8Array字节数组。
数组的最后16字节是tag。
然后前一位是aad的长度,根据这个长度的前n位,就是aad的内容。
然后再前12位,是初始化向量iv。然后再前面就是真正的加密结果。
得到的这些内容,将同样的调用chaEncode函数,就进入了解密流程。在流加密体系中,解密就是将加密的过程,在做一遍异或操作,就可以得到加密原文,就是原始明文了。
然后将原始明文,使用TextDecoder进行解码,就得到原始UTF-8字符串内容。
问题和思考
在了解了CP1305的实现和细节之后,笔者认为,还有一些问题值得思考和讨论。
安全性
作为一个加密算法,人们当然最关心的就是它的安全性了。关于这方面,笔者并没有太多专业方面的基础和研究。但我们可以从行业的采纳性、标准化和应用的深入和广泛,可以体会到到目前为止,基本上没有对这个算法安全方面的担心和质疑(反面的例子,可以参考DES、RC4和MD5)。
如果读者还有疑问,笔者也注意到业界也有很多关于这个算法的安全性方面的研究和讨论,例如下面这篇文章就有比较深入的探讨:
《A Security Analysis of the Composition of ChaCha20 and Poly1305》
eprint.iacr.org/2014/613.pd...
硬件加速
从CP1305的设计角度而言,其实这个算法的实现是不复杂的。代码非常精简紧凑,而且安全性方面也比较完备(密钥长度、认证码等等),所涉及的大量基础操作也是常见的位操作,市场和应用的考验也比较成熟,理论上是非常容易在硬件级别实现,或者使用硬件的方式来进行加速的。但不知道是什么原因,好像很少看到在指令集级别对于CP1305算法的支持。
认证的流
由于密钥是可以按照流的方式生成的,CP1305就可以很容易的来支持流数据的加密和解密方式。但认证方面呢? 如果仔细分析一下它的实现方式,发现也是可以比较好的适应流式的数据的。因为这个认证码,是可以根据当前已经存在的数据,渐进的计算的。验证码的初始数据,是附加信息,然后当流式数据片段进来的时候,当填满一个"块",就可以开始计算当前的认证码,而且这个验证码是基于上一个验证码和块的内容计算而来的,在流数据加载的同时,就可以同时计算,直到数据流结束,可以做一个收尾的计算,而无需对整个数据进行总体计算。这样也就实现了流化的认证计算,也就是说,在加密和认证方面,CP1305都是为流计算设计的。
算法验证和兼容性
文中的示例。可以通过nodejs中的cyrpto模块来进行交互性的验证。但可能更好的方式,是参考标准化的RFC文件。 《ChaCha20 and Poly1305 for IETF Protocols》 (RFC7539 2015)。
相关的开发支持和代码解读,也可以参考这个RFC文件。它还包括了伪代码和示例数据,开发者甚至可以一步一步的用代码和数据来验证自己的实现。
性能
从操作和计算的简洁性而言,再加上理论上Chacha可以很好的支持并行计算,还有流计算的模式,可以明显看到CP1305在计算性能、效率和资源方面对比AES或者这一类的分组加密算法是有一定优势的。
在Poly1305方面,本文的实现,其实并不是特别优雅。现在为了实现和理解的方便,使用的是大整数和hex字符串转换计算的方式。理论上,应该直接使用大整数计算库,或者在字节数组层面进行操作,来获得更加原生高效的性能。(更新的版本已经有了相关的优化)
笔者在RFC中,看到了一份有趣的性能对比,来帮助读者可以理解,为什么说CP1305这个算法,是为了高性能软件实现而设计的。
bash
+----------------------------+-------------+-------------------+
| Chip | AES-128-GCM | ChaCha20-Poly1305 |
+----------------------------+-------------+-------------------+
| OMAP 4460 | 24.1 MB/s | 75.3 MB/s |
| Snapdragon S4 Pro | 41.5 MB/s | 130.9 MB/s |
| Sandy Bridge Xeon (AES-NI) | 900 MB/s | 500 MB/s |
+----------------------------+-------------+-------------------+
数据来源:www.imperialviolet.org/2014/02/27/...
chacha块密钥常量
从YT上看来的。可能有人会有疑问,chacha的块密钥的16个组成部分中,前四个是常量,这些常量是怎么来的呢?笔者原来也认为它是一个基于某种数学原理的设计,其实根本没有那么复杂:
js
static BLK_HEAD = new Uint32Array(
"expand 32-byte k".match(/.{1,4}/g)
.map(v=>v.split("").reverse().map(c=>c.charCodeAt(0).toString(16)).join(""))
.map(i=>parseInt(i,16))
);
//[ '61707865', '3320646e', '79622d32', '6b206574' ]
关于AAD
前面我们已经了解到,CP1305是一个所谓的AEAD认证加密算法。其中除了消息认证之外,还有一个很重要的相关概念,就是AAD (Additional Authenticated Data,附加认证数据)。
关于AAD的正确的认证和理解应当包括下面几个方面:
- AAD 是一段不需要加密但需要认证的数据
- 它参与认证过程但不会被加密
- 在解密时会验证AAD的完整性
从本文前面代码的实现过程中,应该已经能够了解相关的特性。那么,为什么要设计这么一个机制呢? 笔者认为是可以在保证安全的前提下,提供更高的业务和应用的灵活性。包括:
- 提供数据完整性验证而无需加密全部数据
- 允许部分数据可见但仍然保证其完整性
- 防止篡改攻击
- 减少不必要的加解密开销
这样,AAD在真实世界中的常见用途包括:
- 网络协议头部保护
- 加密文件的元数据保护
- 数字签名场景
- 安全通信协议(如 TLS)
JS实现中的一些坑和改进
笔者原来参考的实现代码,其实里面有一些问题。当然如果是自己运行加密和解密,看起来是可以正常运行的,但如果和标准的实现进行交互(比如nodejs crypto模块中的chacha20-poly1305的实现),就会出现问题。笔者在这里耽误了一些时间,最后是查询RFC中的实现和示例数据,经过对比分析,才分步骤的解决这些问题,最后实现了标准操作的。除此之外,笔者还改进了原来的一些算法和设定,下面简单列举一些:
- rotate的实现
这是最主要的问题。rotate是这个算法的一个核心的函数。原来参考的代码是这样的:
static rotate = (v, c) => (v << c) | (v >>> (32 - c)) ;
但在单步的实现分析过程中,发现有时会算出来负数,但算法规则是两个无符号32位整数相加,需要得到一个无符号32位整数。问题就出现在上面的函数定义上。经过研究和尝试,发现需要改为以下的形式,就可以进行正确的计算:
static rotate = (v, c) => ((v << c) | (v >>> (32 - c))) >>> 0;
- Uint32Array创建
直接基于数组,来创建Uint32Array,是有问题的。即下面这个操作,可能会出现预期外的结果(会有一些元素无法赋值):
new Uint32Array([...KEY_HEADER, ...key, counter, ...iv])
出现问题的原因笔者尚不清楚。所以后面选择了其他的处理方式,如使用Array.from()先创建一个临时数组。
- 重复使用KeyBlock
原来的实现方式,对于每个新的KeyBlock,都是需要使用常量、Key的Nonce,结合Counter进行构造的。但其实可以看到,这些KeyBlock的基本结构,其实都是一样的,唯一的差异之处就在于counter所在的元素。所以笔者就在加密计算开始的时候,构造一个模板KeyBlock,然后在每轮生成的时候,直接复制这个块,然后只修改12位置的数值就可以了。
- KeyBlock使用判断
原来的实现设计,是需要判断当前需要加密的字节数量,然后截取所需要的密钥块中的字节。其实完全不需要。现有的方式是直接遍历需要加密的字节流,然后按照需要,生成当前的密钥块来使用,不需要相关的完整性判断,原始字节流结束后,自然密钥字节流的使用就停止了。
需要注意,Chacha使用的密钥流的块的大小,是16个Word,而非原始密钥所使用的16个字节。实际上这个密钥块是扩展和变换出来的,这个和其他一些算法中,密钥块就是原始密钥数组的方式有所不同。
- PolyKey生成和使用
一定要注意,chacha的KeyBlock是一个16个元素的Uint32Array;而PolyKey需要的是一个32个元素的Uint8Array,需要进行截取和转换。当然默认情况下chaBlock生成的就是一个64(16x4)字节的Uint8Array。
- Uint8Array -> Bigint
这个方法,用于将一个字节数组,转换为一个大整数,来参与Poly1305的计算,因为相关的数值相加、相乘、求余,都涉及大整数计算。
原有的实现,是先将字节数组倒置后,转换成为其hex字符串表示,然后基于此字符串创建大整数实例。显然这不是一个高效的做法,新版本中,改进为对字节数组进行reduce和位移操作,理论上可以有更好的性能。这个修改之后,原来的调用方式都有所改变,不需要进行数组的倒置操作了,具体的修改如下:
js
// 原方案
static ary2Hex = uint8Array => Array.from(uint8Array).map(b => b.toString(16).padStart(2, '0')).join('');
// 现方案
static aryBigint = ui8Ary => Array.from(ui8Ary).reduce((c,v,i)=>(c | BigInt(v) << BigInt(i*8)), 0n);
配置信息
相关使用的配置信息,都有明确的可配置定义和来源,包括:
js
static CHA_ROUND = 10; // QRound 轮次
static IV_LENGTH = 12; // Nonce长度
static TAG_LENGTH = 16; // Tag长度
static BLK_SIZE = 64; // 密钥流块大小
static TEXT_KEY = "expand 32-byte k";
static BLK_HEAD = new Uint32Array(
this.TEXT_KEY.match(/.{1,4}/g)
.map(v=>v.split("").map(c=>c.charCodeAt(0)).reduce((c,v,i)=> c | v << (i*8),0))
); // [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]
static POLY_MOD = (1n << 130n) - 5n; // 2^130-5 BigInt('0x3fffffffffffffffffffffffffffffffb');
static POLY_REMAIN = (1n << 128n); // 2^128 16byte
RFC
CP1305算法,是由Google率先采纳、使用和推广的(大约是在2015年左右)。鉴于其产品和技术的巨大影响力,这个算法得到了广泛的检验和应用。市场和业界普遍认可其性能和安全性。所以很快后面就将其标准化,并编入了RFC。相关的RFC包括:
- RFC7315: ChaCha20 and Poly1305 for IETF Protocols
- RFC7539: ChaCha20 and Poly1305 for IETF Protocols
- RFC7634: ChaCha20, Poly1305, and Their Use in the Internet Key Exchange Protocol (IKE) and IPsec
- RFC8103:Using ChaCha20-Poly1305 Authenticated Encryption in the Cryptographic Message Syntax (CMS)
- RFC8439: ChaCha20 and Poly1305 for IETF Protocols (2018年的修订版)
在OpenSSL库中,相关算法标识如下:
0x13,0x03 - TLS_CHACHA20_POLY1305_SHA256 - TLS_CHACHA20_POLY1305_SHA256 TLSv1.3 Kx=any Au=any Enc=CHACHA20/POLY1305(256) Mac=AEAD
0xCC,0xA9 - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 - ECDHE-ECDSA-CHACHA20-POLY1305 TLSv1.2 Kx=ECDH Au=ECDSA Enc=CHACHA20/POLY1305(256) Mac=AEAD
0xCC,0xAE - TLS_RSA_PSK_WITH_CHACHA20_POLY1305_SHA256 - RSA-PSK-CHACHA20-POLY1305 TLSv1.2 Kx=RSAPSK Au=RSA Enc=CHACHA20/POLY1305(256) Mac=AEAD
0xCC,0xAD - TLS_DHE_PSK_WITH_CHACHA20_POLY1305_SHA256 - DHE-PSK-CHACHA20-POLY1305 TLSv1.2 Kx=DHEPSK Au=PSK Enc=CHACHA20/POLY1305(256) Mac=AEAD
致敬D.J.Bernstein
如果说AES算法,是从激烈的对称加密算法竞赛中脱颖而出的话,那么CP1305的地位,实际上是来自互联网技术市场自发的广泛应用和接受,所以笔者认为后者的价值和意义,完全不逊色于前者。
所以这里,笔者想要向此算法和体系最重要的作者,Deniel.J.Bernstein(下图),予以崇高的致敬。其实他还有很多堪称伟大的成就和事迹,包括将自己的名字写在互联网技术体系的RFC文件当中。笔者认为他完全配得上一个图灵奖,读者有兴趣的话,可以自行了解一下。
小结
本文探讨了一个纯JS代码实现的Chacha20-Poly1305的算法。内容从以Chacha为代表的流式加密体系开始,讨论了CP1305的算法构成和流程。然后提供了具体的实现代码,并简单的分析了相关函数、操作和一些技术细节。