Files
AI-Expert-Sidebar/internal/crypto/crypto.go
T
2026-04-01 15:29:35 +08:00

109 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package crypto 提供本项目所有加密/解密能力。
//
// # 加密方案选型
//
// 使用 AES-256-GCMGalois/Counter Mode)对称加密,原因:
// 1. GCM 是认证加密(AEAD),同时提供机密性和完整性验证,
// 可以检测密文被篡改的情况(返回 error 而非静默解密乱码)。
// 2. AES-256 是 NIST 标准,Go 标准库原生支持,无需第三方依赖。
// 3. 相比 RSA 等非对称方案,AES 性能更高、密钥更短,
// 且本场景(本地加密本地解密)不需要非对称性。
//
// # 密钥派生
//
// 不使用用户输入的密码派生密钥(避免需要"主密码"的 UX 摩擦),
// 而是使用固化在应用中的 appSecret 做 SHA-256 哈希得到 32 字节密钥。
//
// 安全边界:此方案防止的是"直接读取 settings.db 文件就能看到 API Key 明文"
// 不能防止"有完整应用二进制 + 数据文件的攻击者",
// 这对于本地隐私工具已经足够。
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
// appSecret 是应用级固定密钥种子。
// 这个字符串不会被写入数据库,仅在运行时存于内存。
// 即便攻击者拿到了 settings.db,没有此二进制也无法解密。
const appSecret = "ai-expert-sidebar-v1-local-©2026"
// deriveKey 将 appSecret 通过 SHA-256 压缩为固定的 32 字节切片,
// 作为 AES-256 的原始密钥(AES-256 要求密钥恰好为 32 字节)。
// SHA-256 是单向函数,无法从输出反推 appSecret。
func deriveKey() []byte {
sum := sha256.Sum256([]byte(appSecret))
return sum[:] // 将 [32]byte 数组转为 []byte 切片
}
// EncryptAPIKey 使用 AES-256-GCM 加密用户输入的 API Key 明文,
// 返回 base64 编码的密文字符串(方便存入 SQLite TEXT 列)。
//
// 空字符串返回空字符串,调用方无需特殊处理。
//
// # Nonce 设计
//
// 每次加密随机生成一个新 nonce(GCM 标准大小:12 字节),
// 并将其前置在密文中(nonce || ciphertext)一起 base64。
// 这样同一个 API Key 每次加密结果都不同,防止重放攻击,
// 且解密时只需从密文头部截取 nonce,无需额外存储。
func EncryptAPIKey(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(deriveKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// 使用密码学安全的随机数生成器(crypto/rand,非 math/rand)产生 nonce
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Seal 将 nonce 作为前缀附加到加密后的密文
// 格式:[nonce(12 bytes)] [ciphertext+tag]
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
// DecryptAPIKey 解密 EncryptAPIKey 产生的 base64 密文,
// 返回原始 API Key 明文。
//
// 空字符串输入返回空字符串(用户尚未配置 Key 的状态)。
// 若密文被篡改或密钥不匹配,GCM 认证会失败,
// 返回 error 而非乱码明文。
func DecryptAPIKey(ciphertext64 string) (string, error) {
if ciphertext64 == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext64)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
block, err := aes.NewCipher(deriveKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(data) < ns {
return "", fmt.Errorf("密文过短,可能已损坏")
}
// 从头部取出 nonce,剩余部分为真正的密文+认证标签
plain, err := gcm.Open(nil, data[:ns], data[ns:], nil)
return string(plain), err
}