feat: let administrators view and copy encrypted integration credentials
This commit is contained in:
111
internal/credentialvault/vault.go
Normal file
111
internal/credentialvault/vault.go
Normal 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
|
||||
}
|
||||
80
internal/credentialvault/vault_test.go
Normal file
80
internal/credentialvault/vault_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package credentialvault
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
func TestCredentialEncryption(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
|
||||
ring, err := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ns, _ := core.NewID()
|
||||
id, _ := core.NewID()
|
||||
other, _ := core.NewID()
|
||||
k, a, err := ring.Seal(ns, id, "test-only-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, b, _ := ring.Seal(ns, id, "test-only-secret")
|
||||
if bytes.Equal(a, b) || bytes.Contains(a, []byte("test-only-secret")) {
|
||||
t.Fatal("nonce reused or plaintext stored")
|
||||
}
|
||||
got, err := ring.Open(ns, id, *k, a)
|
||||
if err != nil || got != "test-only-secret" {
|
||||
t.Fatal("round trip failed")
|
||||
}
|
||||
if _, err = ring.Open(ns, other, *k, a); err == nil {
|
||||
t.Fatal("different credential accepted")
|
||||
}
|
||||
if _, err = ring.Open(other, id, *k, a); err == nil {
|
||||
t.Fatal("different namespace accepted")
|
||||
}
|
||||
if _, err = ring.Open(ns, id, "unknown", a); err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
a[len(a)-1] ^= 1
|
||||
if _, err = ring.Open(ns, id, *k, a); err == nil {
|
||||
t.Fatal("tampered ciphertext accepted")
|
||||
}
|
||||
for _, v := range []string{fmt.Sprint(ring), fmt.Sprintf("%#v", ring)} {
|
||||
if strings.Contains(v, key) || !strings.Contains(v, "REDACTED") {
|
||||
t.Fatal("keyring formatting not redacted")
|
||||
}
|
||||
}
|
||||
old, _ := Read(strings.NewReader(`{"active":"one","keys":{"one":"` + key + `"}}`))
|
||||
k, c, _ := old.Seal(ns, id, "old-secret")
|
||||
newKey := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))
|
||||
rotated, err := Read(strings.NewReader(`{"active":"two","keys":{"one":"` + key + `","two":"` + newKey + `"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := rotated.Open(ns, id, *k, c); err != nil || v != "old-secret" {
|
||||
t.Fatal("retained key cannot read old data")
|
||||
}
|
||||
if k, _, _ := rotated.Seal(ns, id, "new-secret"); *k != "two" {
|
||||
t.Fatal("active key ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialKeyValidation(t *testing.T) {
|
||||
for _, raw := range []string{`{}`, `{"active":"x","keys":{"x":"bad"}}`, `{"active":"missing","keys":{"x":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `"}}`, strings.Repeat(" ", 65537)} {
|
||||
if _, err := Read(strings.NewReader(raw)); err == nil {
|
||||
t.Fatal("invalid key configuration accepted")
|
||||
}
|
||||
}
|
||||
k, err := LoadFile("")
|
||||
if err != nil || k != nil {
|
||||
t.Fatal("empty path must disable vault")
|
||||
}
|
||||
if id, c, err := k.Seal("", "", ""); err != nil || id != nil || c != nil {
|
||||
t.Fatal("disabled vault must use SQL NULL pair")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user