很多刚开始写 Go 后端的朋友,第一次被加解密拦住往往不是在翻密码学教材的时候,而是联调接口时对面甩过来一串密文,你手头只有一把私钥和一句“跟老项目保持一致”。Golang 里常用的加解密机制看起来散,其实翻来覆去就那几类:对称加密、非对称加密、哈希摘要、消息认证码、数字签名。标准库crypto系列包基本覆盖了绝大多数业务需求。
这篇文章不准备把密码学原理从头讲一遍,而是直接以crypto/aes、crypto/rsa、crypto/hmac、crypto/ecdsa这些包为对象,把 Go 项目里最常用的加密解密写法、选型逻辑和踩坑记录捋一遍。适合正在做接口联调、登录态管理、开放平台回调验签、敏感字段落库加密的 Go 开发者参考。
1. 先搞清楚加解密在 Go 项目里到底解决什么问题
很多人在网上抄了一段 AES 加密代码就往项目里塞,结果不是解不开,就是加密出来的东西跟对端对不上。原因很简单:你还没想清楚自己要解决的到底是哪个问题,就把工具拿起来了。
1.1 业务里最常见的三个加解密诉求
第一个是隐私字段落库加密。比如手机号、身份证号、银行卡号存数据库,不能明文保存,这时候用的通常是对称加密。特征很明确:同一个系统或者同一个业务方自己存自己解,密钥不离开服务端。
第二个是接口传输内容加密。前端把业务数据加密后传给后端,或者后端之间通过消息队列传递敏感信息,防止中间人抓到明文、防止日志系统把内容打出来。这种场景如果只是内部链路,对称加密就够;一旦跨系统、跨信任边界,就要考虑非对称加密或者混合加密。
第三个是签名与验签。比如支付回调、开放平台的 webhook,消息里带一个sign字段。它不是要藏住内容,而是要让接收方确认两件事:这段数据没有被篡改,并且它确实来自声称的那一方。这里用的是 MAC 或数字签名,而不是“加密”。
1.2 先分清加密、摘要、签名三件事
把这三个概念混在一起,是绝大多数 Go 加解密问题的根源。我用最朴素的类比来说:
- 加密:明文 + 密钥 -> 密文,密文 + 密钥 -> 明文。可逆。
- 哈希:任意长度数据 -> 固定长度指纹。不可逆。
- 签名:私钥对数据的摘要做运算,任何人可以用公钥验证,但只有私钥持有者才能产生。
所以你可以看到这三者解决的问题完全不同。加密解决的是机密性,哈希解决的是完整性校验,签名解决的是防抵赖和来源认证。
| 机制类型 | 是否可逆 | 典型算法 | 主要解决什么问题 |
|---|---|---|---|
| 对称加密 | 可逆 | AES、ChaCha20 | 数据机密性,速度快 |
| 非对称加密 | 可逆 | RSA、ECIES | 密钥分发、少量数据加密 |
| 消息摘要 | 不可逆 | SHA-256、SHA-512 | 完整性校验 |
| 消息认证码 | 不可逆 | HMAC-SHA256 | 防篡改 + 来源校验(共享密钥) |
| 数字签名 | 私钥签名,公钥验证 | RSA、ECDSA、Ed25519 | 防抵赖 + 完整性 + 来源认证 |
1.3 很多人把哈希当加密用
这里必须单独提一句:MD5、SHA-256 不是加密,是摘要。很多老项目里“MD5 加密密码”的说法其实是错的,因为哈希不可逆,根本没有“解”这个过程。还有人在做接口校验时直接用sha256.Sum256(data)拼个字符串当签名,因为没有密钥,别人完全可以伪造。这一类问题在后续章节会展开讲,你先记住:看到需求里写“加密”,先确认对方到底是要“藏住数据”还是要“验明正身”。
2. 对称加密的主流选择:AES-GCM 为什么碾压 CBC
对称加密在 Go 里绕不开的一个包是crypto/aes。但 AES 只是个底层分组密码,真正决定安全性和易用性的,是工作模式。
2.1 AEAD 是什么,为什么新项目默认选 GCM
AES 有好几种模式,比较常见的有 ECB、CBC、CTR、GCM。ECB 模式在 Go 标准库里甚至没有直接暴露,因为同样的明文会得到同样的密文,不安全。老项目里最经典的是 CBC,但 CBC 有一个致命短板:它只保证机密性,不保证完整性。也就是说,攻击者改了密文里的某些字节,解密端可能会解出错误数据,甚至通过报错信息反推明文,这就是著名的 padding oracle 攻击。
GCM(Galois/Counter Mode)属于 AEAD 类模式,全称是 Authenticated Encryption with Associated Data。它的特点是一次性完成加密和认证:输出的密文后面带一个 tag,解密时如果数据被篡改过,Open方法会直接失败。用 GCM,你不需要再手动叠加一层 HMAC,省掉了一个最容易做错的环节。
2.2 Go 里 AES-256-GCM 的完整实现
直接看代码。标准库crypto/cipher提供了现成的NewGCM,不需要额外引第三方包。
package cryptohelper import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "errors" "io" ) // EncryptGCM 使用 AES-256-GCM 加密,返回 base64 编码的 nonce||ciphertext||tag func EncryptGCM(plaintext []byte, key []byte) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } // Seal 会把 nonce 追加到密文前面,解密时方便取出来 ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) return base64.StdEncoding.EncodeToString(ciphertext), nil } // DecryptGCM 解密 EncryptGCM 生成的密文 func DecryptGCM(data string, key []byte) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(data) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } if len(raw) < gcm.NonceSize() { return nil, errors.New("密文太短,格式不正确") } nonce := raw[:gcm.NonceSize()] ciphertext := raw[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } return plaintext, nil }几个细节说一下:
- key 必须是 16、24 或 32 字节,分别对应 AES-128、AES-192、AES-256。实战中直接用 32 字节,没有理由用更短的。
- nonce 在 Go 里用
gcm.NonceSize()获取,一般是 12 字节。同一把 AES 密钥下,nonce 绝对不能重复。随机生成的 nonce 碰撞概率极低,但如果密钥轮换不勤,仍然要小心。 gcm.Seal(nonce, nonce, plaintext, nil)的第一个参数是 dst,传 nonce 是为了把 nonce 拼到密文头部,这样解密时可以直接切片取出来。- 解密时
gcm.Open返回的 error 只要是非 nil,就说明认证失败,数据已被篡改或密钥不对。这里绝对不能忽略。
2.3 老项目用 CBC 时要注意的补救手段
如果你的老项目已经用了 AES-CBC,短期内不能重构,至少要把下面几点做对:
func PKCS7Pad(data []byte, blockSize int) []byte { pad := blockSize - len(data)%blockSize return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...) }CBC 加解密必须处理填充。Go 标准库没有公开的 PKCS7 填充函数,你得自己写。同时注意:
- IV 必须随机生成,每次加密都不同。固定 IV 会让相同明文产生相同密文,等于没有加密效果。
- 解密成功后最好再做一层 HMAC 校验,否则 CVE 列表里各种 padding oracle 攻击就是给你准备的。
- 能迁移就尽快迁移到 GCM。CBC 在 Go 新项目里没有任何优势。
2.4 Nonce、IV 和密钥的现实管理
加密算法本身往往不是问题,问题出在密钥管理上。我在实际项目里见过密钥直接写在代码常量里的,也见过把密钥打到镜像里的,最后泄露了只能全部数据重加密。
建议分几档:
- 敏感度一般的内部系统:密钥放环境变量,部署时由配置中心下发。
- 核心交易链路:用云厂商的 KMS 或自建密钥管理服务,加密时向 KMS 请求数据密钥,解密时再通过 KMS 解包。
- 密钥轮换时必须带版本号。最常见的设计是密文格式为
v1:base64密文,密钥版本对应到密钥表中的某一把,否则轮换后老数据全解不开。
3. RSA 实战:格式、填充和长度限制才是真正的门槛
RSA 是公钥加密体系里最经典的非对称算法。在 Go 里用 RSA 做加密,真正让人头大的不是函数怎么调,而是密钥格式和填充方式。这两个问题几乎每个上手的人都踩过。
3.1 先算清楚 RSA 能加密多长
RSA 不是大水管,它加密的数据长度有硬上限。原理是 RSA 加密就是把明文变成一个小于 N 的数再模幂运算,所以明文长度不能超过密钥位数对应的字节数。
以常见的2048 位密钥为例:
- 模长 N 是 256 字节。
- 使用 OAEP 填充时,明文上限 = 模长 - 2 * hash长度 - 2。用 SHA-256 时就是 256 - 64 - 2 =190 字节。
- 使用 PKCS1v15 填充时,明文上限 = 模长 - 11 =245 字节。
所以网上那些“RSA 加密超长文本”的需求,本质上不该用 RSA 硬刚,而是应该走混合加密,后面章节专门讲。如果业务里非要直接 RSA 加密长内容,要么分段,要么换方案。分段容易出兼容性 bug,我自己不推荐。
3.2 PKCS1 和 PKCS8 的格式问题
密钥文件在磁盘上是 PEM 编码的,但 PEM 标签和内部编码方式五花八门。Go 里解析私钥最常遇到的报错就是x509: failed to parse private key,多半是格式没判断对。
常见组合:
| PEM 标签 | 编码格式 | Go 解析函数 |
|---|---|---|
RSA PRIVATE KEY | PKCS1 | x509.ParsePKCS1PrivateKey |
PRIVATE KEY | PKCS8 | x509.ParsePKCS8PrivateKey |
PUBLIC KEY | PKIX/SPKI | x509.ParsePKIXPublicKey |
RSA PUBLIC KEY | PKCS1 公钥 | x509.ParsePKCS1PublicKey |
很多在线工具和 OpenSSL 生成出来的私钥是 PKCS8 格式,而有些老项目代码写死了 PKCS1,于是同一个私钥文件,在这边能解,在那边报错。稳妥做法是写一个兼容解析函数:
func ParseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, errors.New("invalid PEM block") } switch block.Type { case "RSA PRIVATE KEY": return x509.ParsePKCS1PrivateKey(block.Bytes) case "PRIVATE KEY": key, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } priv, ok := key.(*rsa.PrivateKey) if !ok { return nil, errors.New("not an RSA private key") } return priv, nil default: return nil, fmt.Errorf("unsupported key type: %s", block.Type) } }公钥解析同理,建议同时支持 PKIX 和 PKCS1 两种标签。这个兼容函数放工具包里,能省掉很多对接时的沟通成本。
3.3 OAEP 与 PKCS1v15:别跟前端打架
Go 的crypto/rsa提供两种填充方式:
rsa.EncryptOAEP:更安全,是密码学界推荐的标准方案。填充中引入随机数,相同明文每次加密结果不同。rsa.EncryptPKCS1v15:老方案,实现简单,但存在 Bleichenbacher 攻击风险,如果不是为了兼容老系统,不要用它。
实际项目中最大的坑是跟第三方对接。很多前端 JS 加密库默认使用RSA_PKCS1_PADDING,对应 Go 的EncryptPKCS1v15。你后端如果用 OAEP 解密,永远解不对,而且两端都不报错,就是出来一堆乱码。加解密填充方式必须前后端对齐,这个要写进接口文档。
3.4 RSA 加密代码示例
下面是一个 RSA 公钥加密、私钥解密的最小实现:
// EncryptWithPublicKey 使用公钥加密,输出 base64 func EncryptWithPublicKey(msg []byte, pub *rsa.PublicKey) (string, error) { ciphertext, err := rsa.EncryptOAEP( sha256.New(), rand.Reader, pub, msg, []byte(""), ) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(ciphertext), nil } // DecryptWithPrivateKey 使用私钥解密 func DecryptWithPrivateKey(data string, priv *rsa.PrivateKey) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(data) if err != nil { return nil, err } plaintext, err := rsa.DecryptOAEP( sha256.New(), rand.Reader, priv, raw, []byte(""), ) if err != nil { return nil, err } return plaintext, nil }注意sha256.New()必须和加密时保持一致。OAEP 的哈希函数是可选的,如果生成密钥时没约定,默认都是 SHA-256。我见过有的系统用 SHA-1 的 OAEP 加密,虽然现在 SHA-1 还没被彻底撞穿,但这种系统最好赶紧升级。
4. 哈希与 HMAC:密码存储和接口签名里的两个典型场景
哈希和 HMAC 是“常用加解密机制”里最容易被误解的部分。它们不是为了隐藏内容,而是为了验证“内容没有被改过”。
4.1 哈希不是加密,MD5/SHA-1 也不要再用了
Go 标准库提供crypto/md5、crypto/sha1、crypto/sha256、crypto/sha512等包。注意crypto/md5和crypto/sha1这两个包虽然在标准库里,但我在新项目里一律不碰。MD5 和 SHA-1 都已经被找到碰撞案例,在数字签名、证书校验、代码完整性等安全敏感场景,它们的抗碰撞性早已不够。
SHA-256 的正确用途是完整性校验,比如下载文件时比对 sha256sum。但密码存储不要用纯 SHA-256,因为彩虹表攻击和 GPU 暴力破解太容易了。密码哈希应该用慢哈希算法,比如 bcrypt、scrypt、Argon2。Go 社区最常用的是golang.org/x/crypto/bcrypt。
import "golang.org/x/crypto/bcrypt" hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) err := bcrypt.CompareHashAndPassword(hash, []byte(password))4.2 HMAC 是“带密钥的哈希”
HMAC 全称 Hash-based Message Authentication Code。它是哈希和密钥的结合:输入数据 + 密钥,输出一个固定长度的 MAC。和普通哈希相比,没有密钥的人无法伪造出合法的 MAC。
接口签名里最常见的做法就是 HMAC-SHA256。计算方式:
func SignHMAC(data []byte, key []byte) string { mac := hmac.New(sha256.New, key) mac.Write(data) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func VerifyHMAC(data []byte, key []byte, expected string) bool { computed := SignHMAC(data, key) return hmac.Equal([]byte(computed), []byte(expected)) }这里有个重要的安全细节:比较 MAC 时必须用hmac.Equal而不是==。==是普通字符串比较,一旦发现第一个字节不同就会提前返回,攻击者可以利用响应时间差逐字节猜出正确 MAC。hmac.Equal内部对所有字节执行固定时间的比较,能防时序攻击。
4.3 接口签名里怎么用时间戳和 nonce 防重放
很多开放平台要求业务方在请求里带timestamp、nonce和sign。sign通常是这样算的:
sign = HMAC(key, timestamp + nonce + body 的规范化字符串)服务端验签时做三件事:
- 检查 timestamp 是否在允许的时间窗口内,比如 5 分钟。太旧的一律拒绝,防止请求被抓包后无限重放。
- 检查 nonce 是否在 Redis 或数据库里出现过。同一个 nonce 在有效期内只能用一次。
- 重新计算 HMAC,比对是否一致。
这个流程是防止 API 被重放攻击的基线设计。别小看 nonce,如果只检查时间戳,攻击者在 5 分钟内原样重发请求,签名仍然是合法的。
5. 数字签名机制:RSA、ECDSA、Ed25519 的选型对比
如果说加密是“藏内容”,签名就是“盖公章”。签名的核心性质是:私钥签名,公钥验签,公钥可以公开,私钥只有你一个人有。所以签名天然有防抵赖的能力,接收方可以把签名和数据公之于众,任何持有公钥的人都能验证这是你发的。
5.1 签名在 Go 项目里的典型应用
JWT 用的 RS256、ES256 就是数字签名。开放平台的回调通知,比如支付回调,通常也要求验签。软件分发时下发的校验文件也是签名。区块链里的交易签名更不用说了。总之,只要涉及“数据可能被篡改 + 来源必须可信 + 事后不能抵赖”,就是签名的地盘。
5.2 三种算法选型对比
| 特性 | RSA-2048 | ECDSA P-256 | Ed25519 |
|---|---|---|---|
| 签名长度 | 256 字节 | 约 70 字节(DER 编码) | 64 字节 |
| 签名速度 | 慢 | 快 | 很快 |
| 密钥生成速度 | 慢 | 快 | 很快 |
| 兼容性 | 最好,几乎所有语言支持 | 好,JWT 场景常见 | 越来越普及,但部分老系统不支持 |
| 推荐场景 | 老系统对接、证书体系 | JWT、TLS 证书 | 新项目、对签名长度敏感的场景 |
从工程角度,新项目我优先推荐 Ed25519。它的公钥和签名都非常短,签名速度快,而且实现上天然抵抗一些侧信道攻击。2017 年之后,OpenSSH、TLS 1.3、很多区块链项目都支持了 Ed25519。唯一需要确认的是,跟你对接的客户端有没有引用这个算法的库。
5.3 Go 里签名与验签的最小实现
RSA 签名长这样:
func SignRSA(priv *rsa.PrivateKey, data []byte) ([]byte, error) { digest := sha256.Sum256(data) return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, digest[:]) } func VerifyRSA(pub *rsa.PublicKey, data []byte, sig []byte) error { digest := sha256.Sum256(data) return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig) }Ed25519 的代码更简洁:
func SignEd25519(priv ed25519.PrivateKey, data []byte) []byte { return ed25519.Sign(priv, data) } func VerifyEd25519(pub ed25519.PublicKey, data []byte, sig []byte) bool { return ed25519.Verify(pub, data, sig) }注意签名是对数据的哈希做运算,不是对原始数据本身做运算。你在设计接口协议时,签名字段要明确说明:签的是原始 body 的字节,还是拼接后的字符串,还是做了规范化处理后的 JSON。很多联调对不上的场景,最后发现是两边签名的内容格式差了一个空格。
5.4 签名和加密不能互相替代
还有一个非常常见的错误:把“私钥加密、公钥解密”当作加密用。虽然 RSA 从数学上看,私钥加密后公钥确实能解开,但这不是加密的标准用法,而且会引发严重的安全问题。私钥加密是在做签名,公钥验签则是在验证签名者的身份。反过来,如果你真的想让全世界任何持有公钥的人都能解出密文,那么你应该用公钥加密,私钥解密。不要把这两者搞混,否则等于把你的私钥暴露在签名验证的 oracle 里,存在很大的数学风险。
6. 被大厂验证过的 AES+RSA 混合加密,拆成工程步骤长这样
前面提过,RSA 加密有长度上限,AES 加密又面临密钥怎么送达的问题。于是出现了混合加密方案:用 RSA 保护 AES 密钥,用 AES 加密真正的业务数据。这也是很多登录系统、验证码流程、开放 API 网关底层采用的双层加密思路,热搜里那个“aes+rsa 双层加密”说的就是这套机制。
6.1 混合加密为什么是工程上的合理选择
AES 很快,但双方得先共有一把密钥。RSA 能解决密钥分发问题,但慢,一次最多加密几百字节。混合加密就是各取所长:客户端随机生成一把 32 字节的 AES 会话密钥,用服务端的 RSA 公钥加密这把 AES 密钥,再用这把 AES 密钥去加密业务数据。服务端收到后先用 RSA 私钥解出 AES 密钥,再用 AES 密钥解业务密文。
密钥分发的问题解决了,性能问题也解决了,而且每个请求的 AES 密钥都是随机生成的,即使某个请求被破解,也不会波及其他历史数据。
6.2 工程步骤拆解
完整链路分七步:
- 客户端生成 32 字节随机 AES 密钥。
- 客户端用服务端 RSA 公钥加密 AES 密钥,得到
encryptedKey。 - 客户端用 AES-GCM 加密业务数据,得到
ciphertext和nonce。 - 客户端把
{encryptedKey, nonce, ciphertext}一起发给服务端。 - 服务端用 RSA 私钥解密
encryptedKey,得到 AES 密钥。 - 服务端用 AES 密钥 + nonce 解密
ciphertext。 - 解密失败则返回错误,并记录日志用于安全审计。
Go 服务端解密流程可以抽象成这样:
type MixedPayload struct { EncryptedKey string `json:"encryptedKey"` Nonce string `json:"nonce"` Ciphertext string `json:"ciphertext"` } func DecryptMixed(priv *rsa.PrivateKey, payload MixedPayload) ([]byte, error) { aesKeyEnc, err := base64.StdEncoding.DecodeString(payload.EncryptedKey) if err != nil { return nil, err } aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, aesKeyEnc, nil) if err != nil { return nil, err } nonce, err := base64.StdEncoding.DecodeString(payload.Nonce) if err != nil { return nil, err } ciphertext, err := base64.StdEncoding.DecodeString(payload.Ciphertext) if err != nil { return nil, err } block, err := aes.NewCipher(aesKey) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } return gcm.Open(nil, nonce, ciphertext, nil) }这套结构在多个验证码系统、登录鉴权系统、开放平台签名里被反复验证过。你可以理解为:RSA 管钥匙,AES 管柜子,各司其职。
6.3 我在实际项目里踩过的三个加解密地雷
讲几个真实出现过的问题,帮你省点排查时间。
地雷一:RSA 直接加密长文本导致报错。有人把一整段用户资料用公钥加密,长度超过 190 字节后rsa.EncryptOAEP直接返回message too long for RSA key size。正确做法是先 AES 加密内容,再用 RSA 加密 AES 密钥。
地雷二:前端 JS 用 PKCS1v15,后端 Go 用 OAEP。双方都以为“RSA 都一样”,结果解出来全是乱码,排查了整整一个下午。最终是前端把 padding 改成 OAEP,或者后端兼容 PKCS1v15。这类细节必须在接口定义时写清楚。
地雷三:Base64 的 URLSafe 和标准格式混用。标准 Base64 里有+/和=,URL 传输时会被转义,导致解密前端传过来的密文失败。要么统一用base64.RawURLEncoding,要么让对端把密文放进 JSON body 而不是 URL 参数里。
地雷四:密钥轮换导致老数据解不开。明文数据从单密钥加密升级为多版本密钥管理时,如果密文格式里没有存版本号,等新密钥一上线,历史数据全部作废。给密文加前缀是最简单的方案。
我个人现在的习惯是:新接口一律 GCM 加 Ed25519,老接口如果被 RSA 格局锁死,至少把密钥长度提到 3072 或 4096 位。加解密这东西,核心不是把密码学原理背得滚瓜烂熟,而是把每个函数的边界条件、参数语义和前后端对齐方式记得清清楚楚。上面这些代码和流程,你直接改改就能用,踩坑记录也都在这里了,剩下的就是放到真实项目里去验证。