// Package crypto 提供口令哈希(argon2id)与字段加密(AES-256-GCM,enc:v1: 前缀)原语。 package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "io" "math/big" "os" "path/filepath" "strings" "sync" "golang.org/x/crypto/argon2" ) const encPrefix = "enc:v1:" var ( encKey []byte encKeyOnce sync.Once encKeyErr error ) // InitEncryptionKey 初始化字段加密密钥: // 优先环境变量 FLOWSTACK_ENCRYPTION_KEY,否则首次启动生成 32 字节随机密钥 // 写入 dataDir/encryption.key(0600),重启复用。 func InitEncryptionKey(dataDir string) error { encKeyOnce.Do(func() { if env := os.Getenv("FLOWSTACK_ENCRYPTION_KEY"); env != "" { encKey = []byte(env) return } keyFile := filepath.Join(dataDir, "encryption.key") if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 { encKey = data return } key := make([]byte, 32) if _, err := rand.Read(key); err != nil { encKeyErr = err return } if err := os.WriteFile(keyFile, key, 0o600); err != nil { encKeyErr = err return } encKey = key }) return encKeyErr } func gcm() (cipher.AEAD, error) { if encKey == nil { return nil, errors.New("加密密钥未初始化") } block, err := aes.NewCipher(encKey) if err != nil { return nil, err } return cipher.NewGCM(block) } // EncryptField AES-256-GCM 加密,返回 enc:v1:base64;空串原样返回 func EncryptField(plaintext string) (string, error) { if plaintext == "" { return "", nil } aead, err := gcm() if err != nil { return "", err } nonce := make([]byte, aead.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } ciphertext := aead.Seal(nonce, nonce, []byte(plaintext), nil) return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil } // DecryptField 解密 enc:v1: 前缀密文;非该格式原样返回(向后兼容) func DecryptField(encrypted string) (string, error) { if !strings.HasPrefix(encrypted, encPrefix) { return encrypted, nil } data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encrypted, encPrefix)) if err != nil { return "", err } aead, err := gcm() if err != nil { return "", err } if len(data) < aead.NonceSize() { return "", errors.New("ciphertext too short") } nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():] plain, err := aead.Open(nil, nonce, ciphertext, nil) if err != nil { return "", err } return string(plain), nil } // IsEncrypted 判断字段是否为加密格式 func IsEncrypted(v string) bool { return strings.HasPrefix(v, encPrefix) } // Mask 敏感值脱敏展示(保留前后各 1/4,中间星号,最长 24 位) func Mask(s string) string { if s == "" { return "" } r := []rune(s) if len(r) <= 4 { return strings.Repeat("*", len(r)) } keep := len(r) / 4 if keep > 4 { keep = 4 } return string(r[:keep]) + strings.Repeat("*", 6) + string(r[len(r)-keep:]) } // HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码) func HashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) combined := append(salt, hash...) return base64.StdEncoding.EncodeToString(combined), nil } // VerifyPassword 常数时间比对口令哈希 func VerifyPassword(password, hashStr string) bool { combined, err := base64.StdEncoding.DecodeString(hashStr) if err != nil || len(combined) < 16 { return false } salt := combined[:16] hash := combined[16:] newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32) return subtle.ConstantTimeCompare(hash, newHash) == 1 } // passwordAlphabet 去掉易混淆字符(0O1lI)的密码字母表 const passwordAlphabet = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ" // RandomPassword 生成 n 位随机密码(crypto/rand,无易混淆字符) func RandomPassword(n int) (string, error) { max := big.NewInt(int64(len(passwordAlphabet))) buf := make([]byte, n) for i := range buf { idx, err := rand.Int(rand.Reader, max) if err != nil { return "", err } buf[i] = passwordAlphabet[idx.Int64()] } return string(buf), nil }