feat: let administrators view and copy encrypted integration credentials

This commit is contained in:
2026-09-27 13:48:26 +08:00
parent 5555a7b342
commit d54bdd9847
38 changed files with 905 additions and 71 deletions

View File

@@ -0,0 +1,111 @@
// Package credentialvault 加密保存管理员可重新查看的当前业务 Token。
// 主密钥来自独立文件,不进入数据库、日志或 API 响应。
package credentialvault
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"regexp"
core "git.yuntuo.hk/k2safe/orbit/model"
)
// Keyring 为不可变密钥环;active 用于新写入,历史密钥用于解密既有记录。
type Keyring struct {
active string
keys map[string]cipher.AEAD
}
func (*Keyring) String() string { return "credentialvault.Keyring{REDACTED}" }
func (k *Keyring) GoString() string { return k.String() }
// LoadFile 读取最多 64 KiB 的 JSON 密钥文件。空路径表示未启用密文保管。
func LoadFile(path string) (*Keyring, error) {
if path == "" {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
return nil, errors.New("cannot open credential key file")
}
defer f.Close()
return Read(io.LimitReader(f, 65537))
}
// Read 校验 active 和 keys;每个 Base64 密钥必须恰好为 32 字节。
func Read(r io.Reader) (*Keyring, error) {
var config struct {
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
raw, err := io.ReadAll(io.LimitReader(r, 65537))
if err != nil || len(raw) > 65536 {
return nil, errors.New("invalid credential key file")
}
if err = json.Unmarshal(raw, &config); err != nil || len(config.Keys) == 0 || len(config.Keys) > 16 {
return nil, errors.New("invalid credential key file")
}
out := &Keyring{active: config.Active, keys: make(map[string]cipher.AEAD)}
for id, encoded := range config.Keys {
key, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || len(key) != 32 || !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`).MatchString(id) {
return nil, errors.New("invalid credential encryption key")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, errors.New("invalid credential encryption key")
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, errors.New("cannot initialize credential encryption")
}
out.keys[id] = aead
}
if out.keys[out.active] == nil {
return nil, errors.New("active credential encryption key is missing")
}
return out, nil
}
func aad(namespace, credential core.ID) []byte {
return []byte("orbit-credential-v1:" + string(namespace) + ":" + string(credential))
}
// Seal 返回密钥标识和 nonce+ciphertext。未启用时两者均为空,兼容只存摘要的管理 CLI。
func (k *Keyring) Seal(namespace, credential core.ID, token string) (*string, []byte, error) {
if k == nil {
return nil, nil, nil
}
if namespace.Validate() != nil || credential.Validate() != nil || len(token) == 0 || len(token) > 512 {
return nil, nil, errors.New("invalid credential encryption input")
}
aead := k.keys[k.active]
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, errors.New("cannot generate credential encryption nonce")
}
id := k.active
return &id, aead.Seal(nonce, nonce, []byte(token), aad(namespace, credential)), nil
}
// Open 只解密指定凭据;调用方必须先认证管理员、校验版本,再核对当前摘要并记录审计。
func (k *Keyring) Open(namespace, credential core.ID, keyID string, ciphertext []byte) (string, error) {
if k == nil {
return "", errors.New("credential encryption is not configured")
}
aead := k.keys[keyID]
if aead == nil || len(ciphertext) < aead.NonceSize()+aead.Overhead() || len(ciphertext) > 1024 {
return "", errors.New("credential ciphertext cannot be decrypted")
}
raw, err := aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], aad(namespace, credential))
if err != nil {
return "", errors.New("credential ciphertext cannot be decrypted")
}
return string(raw), nil
}