Go语言实现SM2国密算法:从原理到工程实践详解
1. 项目概述:为什么用Go实现SM2?
在当前的软件开发领域,数据安全的重要性怎么强调都不为过。无论是用户隐私信息、交易数据,还是系统间的通信,都需要一套可靠、高效且符合国家标准的加密方案来保驾护航。SM2算法,作为国家密码管理局发布的椭圆曲线公钥密码算法标准,正是为此而生。它相比国际通用的RSA算法,在相同安全强度下,密钥更短、计算更快、带宽需求更低,可以说是国密算法中的“明星选手”。
然而,在实际开发中,尤其是在Go语言生态里,虽然官方crypto包提供了丰富的工具,但直接、完整地实现SM2加解密,并处理好密钥管理、签名验签、与非国密系统的兼容等问题,仍然需要开发者自己搭不少“积木”。网上能找到的代码片段要么过于零散,要么只实现了核心的椭圆曲线运算,离“开箱即用”还有一段距离。这正是我动手封装这个项目的初衷——提供一个结构清晰、功能完整、附带详细注释和测试用例的Go语言SM2实现,让你能像调用AES或RSA一样,轻松地在项目中集成国密加密。
这个项目不仅包含了SM2的非对称加解密,还涵盖了密钥对的生成、解析、PEM格式导入导出,以及针对文件、大数据的流式处理示例。无论你是需要为API接口增加国密传输层,还是为本地文件提供国密加密存储,甚至是学习椭圆曲线密码学的实现原理,这份源码都能提供一个扎实的起点。接下来,我会带你从设计思路到代码细节,一步步拆解这个实现。
2. 核心设计思路与架构拆解
2.1 技术选型:为什么是纯Go实现?
面对SM2的实现,第一个问题就是:用CGO绑定现有的国密库(如GMSSL),还是用纯Go实现?我选择了后者。原因有三点:首先是可移植性,一个纯Go的包,意味着你的程序可以go build一次,然后在任何Go支持的平台(Windows, Linux, macOS)上运行,无需担心本地C库的版本和依赖问题。其次是代码透明度与可维护性,所有逻辑都在Go代码里,调试、阅读、修改都更直观。最后是依赖简洁,项目只需要Go标准库和几个常用的辅助库(如encoding/pem),极大减少了项目的复杂性和潜在的依赖冲突。
当然,纯Go实现的挑战在于性能和安全审计。对于性能,Go的math/big包对于大整数运算已经足够高效,能满足绝大多数业务场景。对于安全,本项目严格遵循《GMT 0003-2012 SM2椭圆曲线公钥密码算法》标准文档,核心运算步骤(如密钥派生函数KDF、椭圆曲线点乘)均参照标准实现,并提供了详尽的测试用例,覆盖了标准中的示例向量,以确保算法的正确性。
2.2 项目结构设计
一个清晰的目录结构是项目可维护性的基础。我的项目结构大致如下:
sm2-go/ ├── go.mod ├── go.sum ├── sm2/ │ ├── sm2.go // 核心结构体定义、加解密主逻辑 │ ├── key.go // 密钥生成、解析、PEM格式处理 │ ├── kdf.go // 密钥派生函数实现 │ ├── util.go // 辅助函数(如随机数生成、字节填充) │ └── sm2_test.go // 单元测试和标准示例测试 ├── examples/ │ ├── encrypt_decrypt_file.go // 文件加解密示例 │ ├── stream_processing.go // 流式处理示例 │ └── pem_key_operation.go // PEM密钥操作示例 └── README.mdsm2.go是心脏,定义了PublicKey和PrivateKey结构体,以及最关键的Encrypt和Decrypt函数。key.go负责密钥的生命周期管理,包括从字节流或PEM文件中加载密钥。kdf.go实现了SM2标准中指定的密钥派生函数,这是加解密过程中将共享秘密转换为会话密钥的关键一步。util.go放一些公共工具,比如确保使用密码学安全随机数生成器。这种按功能模块划分的方式,使得代码逻辑清晰,也便于单独测试和复用。
2.3 理解SM2加密的核心流程
在深入代码前,有必要快速过一遍SM2非对称加密的理论流程(简化版)。假设Alice要加密一条消息给Bob:
- Alice获取Bob的SM2公钥
Pb。 - Alice生成一个临时随机数
k。 - 计算椭圆曲线点
C1 = [k]G,其中G是椭圆曲线的基点。C1将作为密文的一部分发送出去。 - 计算共享秘密点
S = [k]Pb。注意,Bob用自己的私钥db计算[db]C1也会得到同一个点S(因为[k]Pb = [k][db]G = [db][k]G = [db]C1),这是椭圆曲线密码学的核心原理。 - 将点S的坐标转换为字节串,然后通过密钥派生函数(KDF)生成一个与明文等长的密钥流。
- 用这个密钥流与明文进行异或(XOR)操作,得到密文主体
C2。 - 最后,对整个流程的输入(包括公钥、C1、C2等)计算一个杂凑值(哈希)作为消息认证码
C3,用于验证密文在传输中是否被篡改。 - 最终的密文由
C1 || C3 || C2三部分组成(||表示拼接)。
解密则是上述过程的逆过程,Bob用自己的私钥从C1推导出共享秘密S,进而得到密钥流,解密C2,并验证C3。
3. 核心模块实现细节解析
3.1 椭圆曲线参数与密钥定义
在Go中,我们首先需要定义SM2使用的椭圆曲线参数。根据国标,SM2推荐使用一条特定的256位素数域椭圆曲线,其参数是固定的。
// 在 sm2.go 中定义 import "math/big" var ( // 椭圆曲线参数 (素数域) sm2P, _ = new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", 16) sm2A, _ = new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", 16) sm2B, _ = new(big.Int).SetString("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", 16) // 基点G和其阶n sm2Gx, _ = new(big.Int).SetString("32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7", 16) sm2Gy, _ = new(big.Int).SetString("BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0", 16) sm2N, _ = new(big.Int).SetString("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123", 16) ) // PublicKey 代表SM2公钥,即椭圆曲线上的一个点 type PublicKey struct { X, Y *big.Int } // PrivateKey 代表SM2私钥,一个大整数 type PrivateKey struct { D *big.Int PublicKey // 内嵌公钥,方便使用 }这里使用了math/big.Int来处理大整数,这是Go中进行密码学运算的基础。公钥是曲线上的一个点(X, Y),私钥D是一个在[1, n-1]范围内的随机大整数。内嵌PublicKey的设计很实用,因为从私钥可以直接推导出公钥(Pub = [D]G),这样在生成密钥对时,只需保存私钥对象,就能同时访问公钥。
3.2 密钥派生函数(KDF)的实现
KDF是SM2加密中的一个关键步骤,它负责将椭圆曲线点交换产生的共享秘密(一个坐标点),扩展成与明文等长的密钥流。国标SM2使用的是基于SM3哈希函数的KDF。
// 在 kdf.go 中 import ( "hash" "github.com/your-repo/sm3" // 假设有一个Go的SM3实现 ) func KDF(z []byte, klen int) ([]byte, error) { // klen 是期望生成的密钥流长度(单位:字节) if klen == 0 { return nil, errors.New("klen must be greater than 0") } var derived []byte ct := 0x00000001 // 计数器 hasher := sm3.New() for len(derived) < klen { hasher.Reset() hasher.Write(z) hasher.Write(intToBytes(ct)) // 将计数器转为4字节大端序 hash := hasher.Sum(nil) derived = append(derived, hash...) ct++ } return derived[:klen], nil }这个函数逻辑很清晰:将共享秘密z和不断递增的计数器一起做SM3哈希,将每次的哈希结果拼接起来,直到达到所需的长度klen。这里有个细节,intToBytes函数需要将32位整数转为4字节的大端序(Big-Endian)字节切片,这是标准规定的格式。
注意:KDF的安全性。确保输入的
z(共享秘密的某种编码)具有足够高的熵。在SM2加密中,z通常来源于椭圆曲线点的X坐标和Y坐标的拼接。此外,计数器ct从1开始递增,且哈希函数每次必须重置(Reset),避免状态累积导致输出不安全。
3.3 加密函数的完整实现
有了上面的基础,我们可以来看核心的Encrypt函数。它接收一个公钥和明文,输出符合SM2标准的密文。
// 在 sm2.go 中 func Encrypt(pub *PublicKey, data []byte) ([]byte, error) { // 1. 输入校验 if pub == nil || len(data) == 0 { return nil, errors.New("invalid input") } // 2. 生成临时随机数k,范围在[1, n-1] k, err := randFieldElement(sm2N) if err != nil { return nil, err } // 3. 计算 C1 = [k]G, 并将其转换为字节串 (使用压缩或未压缩格式,这里用未压缩04||X||Y) c1x, c1y := curvePointMul(sm2Gx, sm2Gy, k) // 曲线点乘运算 c1Bytes := pointToBytes(c1x, c1y, false) // false 表示未压缩格式 // 4. 计算共享秘密点 S = [k]Pb sx, sy := curvePointMul(pub.X, pub.Y, k) // 5. 将点S的坐标转换为字节串z,用于KDF。标准规定为 x||y z := append(sx.Bytes(), sy.Bytes()...) // 6. 通过KDF生成密钥流,长度等于明文长度 keyStream, err := KDF(z, len(data)) if err != nil { return nil, err } // 7. 生成密文C2: C2 = data XOR keyStream c2 := make([]byte, len(data)) for i := 0; i < len(data); i++ { c2[i] = data[i] ^ keyStream[i] } // 8. 计算杂凑值C3 = Hash(z || data) hasher := sm3.New() hasher.Write(z) hasher.Write(data) c3 := hasher.Sum(nil) // 9. 输出密文: C1 || C3 || C2 ciphertext := append(c1Bytes, c3...) ciphertext = append(ciphertext, c2...) return ciphertext, nil }这个函数清晰地映射了理论步骤。其中curvePointMul和pointToBytes是椭圆曲线运算和序列化的辅助函数,需要根据椭圆曲线公式实现点加和倍点运算。randFieldElement函数必须使用密码学安全的随机源(crypto/rand)。
实操心得:随机数k的管理。临时随机数
k的生成是加密安全性的命门。必须确保:第一,随机性足够好,使用crypto/rand;第二,每次加密都必须使用新的、不可预测的k。重复使用k加密不同消息,或者k可预测,会导致共享秘密泄露,严重威胁安全。在实际封装中,可以将随机数生成器作为可配置项注入,方便测试,但生产环境必须用安全的随机源。
3.4 解密函数的实现与要点
解密是加密的逆过程,但需要处理更多的错误情况。
func Decrypt(priv *PrivateKey, ciphertext []byte) ([]byte, error) { // 1. 解析密文,获取C1, C3, C2 // 假设ciphertext格式为: (未压缩点标识04, 32字节X, 32字节Y) || 32字节C3 || 变长C2 if len(ciphertext) < 1+32+32+32 { // 04 + X + Y + C3 的最小长度 return nil, errors.New("ciphertext too short") } // 解析C1点 c1x, c1y, err := bytesToPoint(ciphertext[:65]) // 前65字节是未压缩格式点 if err != nil { return nil, err } // 检查C1点是否在曲线上 if !isPointOnCurve(c1x, c1y) { return nil, errors.New("invalid point C1") } c3 := ciphertext[65:97] // 接下来32字节是C3 c2 := ciphertext[97:] // 剩余部分是C2 // 2. 计算共享秘密点 S = [db]C1 sx, sy := curvePointMul(c1x, c1y, priv.D) // 3. 计算 z = x||y z := append(sx.Bytes(), sy.Bytes()...) // 4. 通过KDF生成密钥流 keyStream, err := KDF(z, len(c2)) if err != nil { return nil, err } // 5. 解密得到明文: data = C2 XOR keyStream data := make([]byte, len(c2)) for i := 0; i < len(c2); i++ { data[i] = c2[i] ^ keyStream[i] } // 6. 验证C3: 计算 u = Hash(z || data),比较 u == C3 hasher := sm3.New() hasher.Write(z) hasher.Write(data) u := hasher.Sum(nil) if subtle.ConstantTimeCompare(u, c3) != 1 { return nil, errors.New("sm2: decryption failure") // 认证失败,可能是密文被篡改或密钥错误 } return data, nil }解密函数有几个关键点:第一是密文格式解析,必须和加密时生成的格式严格对应。第二是椭圆曲线点的验证,必须检查C1点是否在规定的曲线上,这是一个重要的安全步骤,可以防止无效曲线攻击。第三是使用恒定时间比较subtle.ConstantTimeCompare来验证杂凑值C3,防止基于时间侧信道的攻击。
4. 密钥管理与PEM格式支持
4.1 生成SM2密钥对
生成密钥对相对简单:生成一个随机私钥d,然后计算公钥点P = [d]G。
// 在 key.go 中 func GenerateKey() (*PrivateKey, error) { // 生成一个在[1, n-1]范围内的随机数作为私钥d d, err := randFieldElement(sm2N) if err != nil { return nil, err } // 计算公钥点 P = [d]G px, py := curvePointMul(sm2Gx, sm2Gy, d) priv := &PrivateKey{ D: d, PublicKey: PublicKey{ X: px, Y: py, }, } return priv, nil }4.2 PEM格式的导入与导出
在实际应用中,密钥经常需要保存到文件或进行传输。PEM(Privacy-Enhanced Mail)格式是一种广泛使用的、基于Base64编码的文本格式,常用于存储证书和密钥。
导出私钥到PEM文件:
import ( "crypto/x509" "encoding/pem" ) func WritePrivateKeyToPEM(key *PrivateKey, password []byte) ([]byte, error) { // 1. 将私钥结构体转换为PKCS#8格式的DER编码字节流 // 这里需要实现一个函数将PrivateKey的D和曲线参数编码为ASN.1 DER序列 derBytes, err := marshalSM2PrivateKey(key) if err != nil { return nil, err } // 2. 可选:使用密码对DER字节流进行加密(如PBES2) var block *pem.Block if len(password) > 0 { encryptedDER, err := encryptPEMBlock(derBytes, password) if err != nil { return nil, err } block = &pem.Block{ Type: "ENCRYPTED PRIVATE KEY", Bytes: encryptedDER, } } else { block = &pem.Block{ Type: "PRIVATE KEY", // 或 "SM2 PRIVATE KEY" Bytes: derBytes, } } // 3. 编码为PEM格式 return pem.EncodeToMemory(block), nil }从PEM文件加载私钥:
func ReadPrivateKeyFromPEM(pemBytes, password []byte) (*PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, errors.New("failed to decode PEM block") } var derBytes []byte var err error if block.Type == "ENCRYPTED PRIVATE KEY" { // 需要解密 derBytes, err = decryptPEMBlock(block.Bytes, password) } else if block.Type == "PRIVATE KEY" || block.Type == "SM2 PRIVATE KEY" { derBytes = block.Bytes } else { return nil, errors.New("unsupported PEM block type") } // 解析DER字节流,还原PrivateKey结构体 return parseSM2PrivateKey(derBytes) }公钥的PEM处理类似,类型通常为PUBLIC KEY。这里的关键在于序列化格式。SM2密钥在PKCS#8或X.509中的具体ASN.1结构需要仔细定义,以确保与其他国密库(如OpenSSL的国密分支)的互操作性。一个常见的做法是使用OID 1.2.156.10197.1.301来标识SM2算法。
注意事项:密码保护与安全性。如果使用密码加密PEM文件,务必选择一个强密码,并安全地管理它。加密过程应使用标准的、安全的算法(如AES-256-CBC或GCM模式)。在内存中处理完密码后,应及时清空存储密码的字节切片,以减少敏感信息在内存中的残留时间。
5. 进阶应用与性能优化
5.1 文件与大数据的流式处理
直接调用Encrypt和Decrypt处理大文件会占用大量内存,因为需要一次性读入所有数据。对于大文件或流式数据,可以采用“分段加密”的策略。思路是:对于加密,生成一个随机会话密钥(比如一个32字节的AES密钥),用SM2加密这个会话密钥,然后用更快的对称算法(如SM4)在流式模式下加密文件数据。解密时反之。
// 简化示例:封装文件加密 func EncryptFile(pub *PublicKey, inputPath, outputPath string) error { // 1. 生成一个随机的会话密钥 (sessionKey) sessionKey := make([]byte, 32) // 假设用于SM4 if _, err := rand.Read(sessionKey); err != nil { return err } // 2. 用SM2公钥加密会话密钥 encryptedKey, err := Encrypt(pub, sessionKey) if err != nil { return err } // 3. 打开输入和输出文件 inFile, err := os.Open(inputPath) if err != nil { return err } defer inFile.Close() outFile, err := os.Create(outputPath) if err != nil { return err } defer outFile.Close() // 4. 将加密后的会话密钥长度和内容写入输出文件头部 keyLenBuf := make([]byte, 4) binary.BigEndian.PutUint32(keyLenBuf, uint32(len(encryptedKey))) outFile.Write(keyLenBuf) outFile.Write(encryptedKey) // 5. 使用会话密钥初始化一个对称加密器 (如SM4-CTR) blockCipher, _ := sm4.NewCipher(sessionKey) // 假设有SM4实现 iv := make([]byte, blockCipher.BlockSize()) rand.Read(iv) // 生成随机IV outFile.Write(iv) stream := cipher.NewCTR(blockCipher, iv) // 6. 流式加密文件内容并写入 writer := &cipher.StreamWriter{S: stream, W: outFile} _, err = io.Copy(writer, inFile) return err }这种方式结合了非对称加密的密钥分发优势和对称加密的速度优势,非常适合大文件。解密文件时,先从文件头部读取加密的会话密钥,用SM2私钥解密得到sessionKey,再用它初始化对称解密流,解密剩余的文件内容。
5.2 性能考量与优化点
纯Go实现的SM2在性能上可以满足大多数应用,但仍有优化空间:
- 椭圆曲线运算优化:点乘(
[k]P)是性能瓶颈。实现时可以采用更高效的算法,如滑动窗口法(Sliding Window)或蒙哥马利阶梯(Montgomery Ladder)。蒙哥马利阶梯的优点是运算时间相对固定,有助于抵御某些侧信道攻击。 - 大整数运算:
math/big.Int的运算开销较大。对于极度追求性能的场景,可以考虑使用汇编优化特定平台(如AMD64)上的底层模运算。不过这会牺牲可移植性,且实现复杂。 - 内存与对象复用:在频繁加解密的场景(如网关服务器),避免在每次操作中频繁分配和垃圾回收
big.Int和字节切片。可以设计一个对象池(sync.Pool)来复用这些临时对象。 - 并发安全:确保
PublicKey和PrivateKey结构体是只读的,或者其方法是并发安全的。如果内部有缓存(如预计算的点),需要妥善处理并发访问。
一个简单的点乘优化示例(滑动窗口法思想):
func scalarMult(baseX, baseY, k *big.Int) (x, y *big.Int) { // 预计算点表:P, 2P, 3P, ... (2^w - 1)P, w是窗口宽度 precomputed := precomputePoints(baseX, baseY, windowSize) resultX, resultY := nil, nil // 表示无穷远点(零点) // 从k的最高位开始扫描,以w位为窗口 for i := k.BitLen() - 1; i >= 0; i -= windowSize { if resultX != nil { // 将当前结果点倍点w次 for j := 0; j < windowSize; j++ { resultX, resultY = pointDouble(resultX, resultY) } } // 获取当前w位的值 windowBits := getWindowBits(k, i, windowSize) if windowBits > 0 { // 加上预计算表中对应的点 px, py := precomputed[windowBits] if resultX == nil { resultX, resultY = px, py } else { resultX, resultY = pointAdd(resultX, resultY, px, py) } } } return resultX, resultY }6. 常见问题与调试技巧实录
在实际集成和使用自实现的SM2库时,你可能会遇到一些典型问题。下面是我踩过的一些坑和解决方法。
6.1 密文格式不兼容
问题描述:你的程序加密的数据,另一个使用不同SM2库(如BouncyCastle、腾讯KMS)的程序无法解密,反之亦然。
根本原因:SM2标准规定了算法流程,但密文的字节序列组织方式(如C1点用压缩格式还是未压缩格式、C1||C3||C2还是C1||C2||C3的顺序)可能存在差异。有些实现为了兼容旧版或特定硬件,会采用不同的格式。
排查与解决:
- 确定对方格式:首先查阅对方库的文档或源码,明确其密文格式。常见的有:
- ASN.1 DER编码格式:将
C1,C3,C2打包成一个ASN.1序列。 - 简单拼接格式:
C1 || C3 || C2或C1 || C2 || C3。 C1点格式:未压缩(04||X||Y)、压缩(02||X或03||X,取决于Y的奇偶性)。
- ASN.1 DER编码格式:将
- 编写适配层:在加密后或解密前,编写一个转换函数。例如,如果你的库输出
04||X||Y || C3 || C2,而对方需要ASN.1格式,你就需要实现一个encodeToASN1函数。 - 使用标准测试向量验证:国标文档附录中有标准的测试向量。确保你的加解密函数能通过这些测试。这是验证核心算法正确性的第一步,然后再去处理格式兼容性问题。
6.2 解密失败:“sm2: decryption failure”
这是最常见的错误,提示杂凑值C3验证失败。
可能原因及排查步骤:
- 密钥不匹配:这是最可能的原因。确保解密使用的私钥,正是加密所用公钥对应的那个私钥。检查密钥加载过程,PEM解析是否正确,密码(如果有)是否正确。
- 密文被篡改或传输错误:检查密文在传输或存储过程中是否发生了任何改变,哪怕一个字节。在网络传输中,确保使用安全的通道,并考虑添加额外的传输层校验(如TLS)。
- 密文格式解析错误:你的
Decrypt函数对密文长度的判断、对C1点格式的解析必须与Encrypt函数完全一致。仔细核对pointToBytes和bytesToPoint这一对函数。 - KDF实现不一致:确认KDF函数中计数器的初始值、递增方式、哈希函数调用(是否重置)、以及
z的构成(是x||y还是x坐标的某种编码)是否与加密端完全一致。 - 随机数k的生成:在极少数情况下,如果加密时使用的随机数
k导致共享秘密点S是无穷远点(概率极低),解密也会失败。良好的加密实现应检查[k]Pb不是无穷远点。
调试技巧:在加密和解密函数中增加详细的日志,打印出中间值(如k,C1,S点的坐标,z,keyStream的前几个字节,计算出的C3和收到的C3)。通过对比加密端和解密端的日志,可以快速定位分歧发生在哪一步。生产环境中记得关闭这些调试日志。
6.3 性能瓶颈排查
如果发现加解密速度慢,可以按以下步骤排查:
- 性能分析:使用Go的
pprof工具进行CPU分析。运行go test -bench=. -cpuprofile=cpu.prof,然后使用go tool pprof查看热点。通常,math/big.Int的模乘、模逆运算和椭圆曲线点加、倍点运算是最耗时的。 - 检查曲线参数和运算函数:确认点乘、点加等函数没有不必要的内存分配或大整数拷贝。例如,在循环中尽量复用
big.Int变量,而不是每次都new(big.Int)。 - 密钥长度:SM2的256位密钥已经比2048位的RSA快很多。如果仍觉得慢,确认是否误用了更大的密钥长度(虽然标准固定为256位)。
- 并发场景:如果是高并发服务,检查是否有锁竞争。确保你的库是并发安全的,或者考虑为每个goroutine创建独立的加密上下文。
6.4 与其它语言/平台的交互
场景:你的Go服务需要解密由Java(BouncyCastle)加密的数据。
挑战:
- 密钥格式:Java可能将密钥存储在JKS或PKCS12密钥库中,而Go使用PEM。你需要用
keytool或openssl命令将密钥导出为Go能识别的PEM格式。 - 密文格式:如前所述,BouncyCastle默认可能使用ASN.1 DER编码的密文。你需要找到BouncyCastle中设置
C1C3C2或C1C2C3顺序的选项(通常通过SM2Engine或SM2ParameterSpec设置),或者在你的Go端编写一个ASN.1解析器。 - 签名/验签:如果涉及签名,还需要注意签名算法标识和哈希算法的对应关系。
建议:建立一个跨语言测试套件。用Java写一个简单的加密程序,输出密钥和密文的十六进制字符串。在Go中编写对应的解密测试用例,确保能成功解密。这个测试套件能持续保证跨平台交互的稳定性。
7. 项目测试与持续集成
一个健壮的密码学库必须有完备的测试。我的测试文件sm2_test.go主要包含以下几类测试:
- 单元测试:针对每个导出函数(
Encrypt,Decrypt,KDF,GenerateKey等)编写测试,使用固定的输入和预期的输出。 - 标准向量测试:这是最重要的测试。从国标文档或权威测试网站获取标准的(公钥,私钥,明文,密文)四元组,测试加解密是否能正确还原。
func TestEncryptDecryptWithStandardVector(t *testing.T) { // 从标准文档中获取的测试向量 pubX, _ := new(big.Int).SetString("...", 16) pubY, _ := new(big.Int).SetString("...", 16) privD, _ := new(big.Int).SetString("...", 16) plaintext, _ := hex.DecodeString("...") expectedCiphertext, _ := hex.DecodeString("...") pub := &PublicKey{X: pubX, Y: pubY} priv := &PrivateKey{D: privD, PublicKey: *pub} // 测试加密 ciphertext, err := Encrypt(pub, plaintext) if err != nil { t.Fatal(err) } // 注意:由于加密引入随机数k,密文每次不同,不能直接比较。 // 但可以用对应私钥解密,看是否能得到原明文。 decrypted, err := Decrypt(priv, ciphertext) if err != nil { t.Fatal(err) } if !bytes.Equal(decrypted, plaintext) { t.Error("decryption result mismatch") } // 测试用标准密文解密 decryptedFromStd, err := Decrypt(priv, expectedCiphertext) if err != nil { t.Fatal(err) } if !bytes.Equal(decryptedFromStd, plaintext) { t.Error("decryption from standard ciphertext failed") } } - 随机性测试:运行成千上万次随机加密解密,确保没有错误。
- 边界测试:测试空明文、超长明文、无效密钥、无效密文等情况,确保函数能优雅地处理错误,而不是panic。
- 性能基准测试:使用Go的
testing.B进行基准测试,评估加解密的速度,为优化提供依据。func BenchmarkEncrypt(b *testing.B) { priv, _ := GenerateKey() pub := &priv.PublicKey data := make([]byte, 1024) // 1KB数据 rand.Read(data) b.ResetTimer() for i := 0; i < b.N; i++ { Encrypt(pub, data) } }
将测试集成到CI/CD流程(如GitHub Actions)中,每次提交都自动运行测试,能极大保证代码质量。对于密码学库,测试的完备性就是安全性的基石。
最后,关于这个项目的源码,我建议你在实现和理解上述所有模块后,将其组织成一个独立的Go模块,并发布到代码托管平台。清晰的文档、丰富的示例(如examples/目录下的文件)和通过所有测试的构建状态,会让其他开发者更愿意使用和贡献你的代码。记住,密码学代码需要审慎对待,公开的代码经过更多人的审查,往往更安全。
