Files
FlowStack/backend/internal/crypto/crypto.go
T
Logan.Li 26341c3e8a feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)
后端(35+ 新路由):
- AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key
- 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/
  JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列,
  PipelineRun 扩展 buildNumber/externalUrl
- 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、
  工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、
  入站流水线触发 /api/hooks/pipeline/:id
- Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/
  buildWithParameters/Location 队列/progressiveText 增量控制台,
  服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、
  jenkins 类型流水线真实执行回传日志
- 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表
- 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进

前端(14 页全部去 mock):
- 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/
  系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API
- 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史)
- TopBar 真实流水线切换 + 仅保存/保存并运行
- Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
2026-10-27 03:15:20 +08:00

169 lines
4.2 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 提供口令哈希(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
}