ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Go 后端加解密实战:AES-GCM、RSA 与数字签名详解

Go 后端加解密实战:AES-GCM、RSA 与数字签名详解 很多刚开始写 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 是什么为什么新项目默认选 GCMAES 有好几种模式比较常见的有 ECB、CBC、CTR、GCM。ECB 模式在 Go 标准库里甚至没有直接暴露因为同样的明文会得到同样的密文不安全。老项目里最经典的是 CBC但 CBC 有一个致命短板它只保证机密性不保证完整性。也就是说攻击者改了密文里的某些字节解密端可能会解出错误数据甚至通过报错信息反推明文这就是著名的 padding oracle 攻击。GCMGalois/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 KEYPKCS1x509.ParsePKCS1PrivateKeyPRIVATE KEYPKCS8x509.ParsePKCS8PrivateKeyPUBLIC KEYPKIX/SPKIx509.ParsePKIXPublicKeyRSA PUBLIC KEYPKCS1 公钥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-2048ECDSA P-256Ed25519签名长度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. 被大厂验证过的 AESRSA 混合加密拆成工程步骤长这样前面提过RSA 加密有长度上限AES 加密又面临密钥怎么送达的问题。于是出现了混合加密方案用 RSA 保护 AES 密钥用 AES 加密真正的业务数据。这也是很多登录系统、验证码流程、开放 API 网关底层采用的双层加密思路热搜里那个“aesrsa 双层加密”说的就是这套机制。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 位。加解密这东西核心不是把密码学原理背得滚瓜烂熟而是把每个函数的边界条件、参数语义和前后端对齐方式记得清清楚楚。上面这些代码和流程你直接改改就能用踩坑记录也都在这里了剩下的就是放到真实项目里去验证。
返回列表