密码学基础:哈希函数、加盐哈希、对称与非对称加密

说实话,很多后端工程师一听到「密码学」三个字就头大。我当年也一样,觉得这是数学家的事。直到有一次,我负责的接口被拖了库,用户密码明文躺在数据库里……嗯,那场面,别提多尴尬了。

从那以后,我养成了一个习惯:涉及安全的地方,绝不自己造轮子。Go 标准库里的 crypto 包,就是咱们最可靠的伙伴。今天咱们就把它吃透。

哈希函数:SHA-256

哈希函数,说白了就是「指纹提取器」。你给它任意长度的数据,它给你一个固定长度的摘要。而且这个摘要是不可逆的——你没法从摘要反推出原文。

SHA-256 是目前最常用的哈希算法之一。输出 256 位,也就是 32 个字节。我在项目中常用它来做数据完整性校验,比如验证下载的文件有没有被篡改。

核心特性:

  • 单向性:无法从哈希值还原原始数据
  • 抗碰撞性:很难找到两个不同输入产生相同哈希值
  • 雪崩效应:输入微小变化,输出天差地别
package main

import (
    "crypto/sha256"
    "fmt"
)

func main() {
    data := []byte("hello, 安全世界")
    hash := sha256.Sum256(data)
    fmt.Printf("SHA-256: %x\n", hash)
    // 输出: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
}

你看,代码就这么几行。但注意:千万别直接用 SHA-256 存密码。为什么?因为彩虹表攻击。攻击者提前算好常见密码的哈希值,一比对就出来了。我曾经见过一个项目,密码字段存的就是裸 SHA-256,吓得我赶紧让他们改了。

加盐哈希:bcrypt

那密码到底该怎么存?答案是:加盐哈希

「盐」就是一段随机字符串,跟密码拼在一起再哈希。每个用户的盐都不一样,这样就算两个用户密码相同,存到数据库里的哈希值也不同。攻击者就算拿到彩虹表,也得针对每个盐重新算一遍——成本高到他们直接放弃。

我个人习惯用 bcrypt。它内置了盐,而且可以调节计算成本(cost factor)。成本越高,暴力破解越慢。我在生产环境一般设成 10 或 12,用户体验和安全性之间取个平衡。

避坑指南:我曾经把 cost 设成 15,结果注册接口响应时间飙到 3 秒。用户疯狂投诉……后来调回 10,响应时间降到 200ms 以内,安全级别也够用。

import "golang.org/x/crypto/bcrypt"

func hashPassword(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(bytes), err
}

func checkPassword(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

注意:bcrypt 不是标准库,需要 go get golang.org/x/crypto/bcrypt。但放心,这是 Go 官方维护的子仓库,质量有保障。

对称加密:AES

哈希是单向的,加密是双向的。对称加密就是「一把钥匙开一把锁」——加密和解密用同一个密钥。

AES 是目前最主流的对称加密算法。Go 标准库 crypto/aes 提供了完整支持。但这里有个坑:AES 本身只处理 16 字节的数据块。你要加密长文本,得搭配一个「工作模式」。

模式 特点 我的建议
ECB 简单,但相同明文块产生相同密文块 ❌ 别用,不安全
CBC 需要 IV,密文依赖前一个块 ✅ 可用,注意 IV 随机
GCM 自带认证,防篡改 ✅ 强烈推荐

我个人强烈推荐 AES-GCM。它同时提供加密和完整性校验,一步到位。我在做 API 参数加密时,用的就是 GCM 模式。

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "io"
)

func encryptAESGCM(key, plaintext []byte) ([]byte, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return nil, err
    }

    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return nil, err
    }

    nonce := make([]byte, gcm.NonceSize())
    if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
        return nil, err
    }

    ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
    return ciphertext, nil
}

注意:密钥管理是门大学问。千万别把密钥硬编码在代码里。我一般用环境变量或专门的密钥管理服务(如 Vault)。

非对称加密:RSA

对称加密有个老大难问题:密钥怎么安全地传给对方?非对称加密就是来解决这个的。

RSA 用一对密钥:公钥加密,私钥解密。公钥可以公开,私钥自己藏好。我在项目中常用 RSA 来做「密钥交换」——用 RSA 加密 AES 的密钥,然后双方用 AES 进行后续通信。这样既安全又高效。

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/sha256"
)

func generateRSAKeyPair() (*rsa.PrivateKey, error) {
    privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
    if err != nil {
        return nil, err
    }
    return privateKey, nil
}

func encryptRSA(publicKey *rsa.PublicKey, data []byte) ([]byte, error) {
    ciphertext, err := rsa.EncryptOAEP(
        sha256.New(), rand.Reader, publicKey, data, nil,
    )
    return ciphertext, err
}

这里有个细节:RSA 加密的数据长度有限制。2048 位的密钥,最多只能加密 190 字节左右。所以实际应用中,RSA 只用来加密对称密钥或签名,不直接加密大块数据。

Go crypto 库实战建议

说了这么多,总结几条我在实战中踩过的坑:

  • 别自己实现密码算法——用标准库或官方子仓库。我曾经见过有人自己写了个「增强版 AES」,结果有后门……
  • 随机数一定要用 crypto/rand——别用 math/rand,那玩意是伪随机的,不安全。
  • 密钥定期轮换——我一般设 90 天有效期,到期自动生成新密钥。
  • 日志里别打印密钥或密文——有一次排查问题,同事把私钥打到了日志里,吓得我连夜改配置。

一句话总结:哈希用 SHA-256(但别存密码),密码用 bcrypt,数据加密用 AES-GCM,密钥交换用 RSA。Go 标准库都给你准备好了,别自己折腾。

好了,这一章的内容就到这里。下一章咱们聊聊「JWT 令牌的设计与安全陷阱」,到时候我会分享一个我踩过的特别深的坑……嗯,先卖个关子。