109 lines
3.8 KiB
Go
109 lines
3.8 KiB
Go
// Package crypto 提供本项目所有加密/解密能力。
|
||
//
|
||
// # 加密方案选型
|
||
//
|
||
// 使用 AES-256-GCM(Galois/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
|
||
}
|