client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Vault 凭据保险库:AES-256-GCM 加密的 JSON 文件(主密钥单独 0600 文件)。
|
||||
// 二期:Windows 上主密钥改用 DPAPI 保护,文件格式不变。
|
||||
type Vault struct {
|
||||
dir string
|
||||
key []byte
|
||||
}
|
||||
|
||||
type vaultFile struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]string `json:"entries"`
|
||||
}
|
||||
|
||||
// Open 打开/创建保险库(主密钥 data/master.key,32 字节)
|
||||
func Open(dir string) (*Vault, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPath := filepath.Join(dir, "master.key")
|
||||
var key []byte
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
key = make([]byte, base64.RawStdEncoding.DecodedLen(len(raw)))
|
||||
if _, err = base64.RawStdEncoding.Decode(key, raw); err != nil || len(key) != 32 {
|
||||
return nil, errors.New("master key corrupted")
|
||||
}
|
||||
} else {
|
||||
key = make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(keyPath, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Vault{dir: dir, key: key}, nil
|
||||
}
|
||||
|
||||
func (v *Vault) path() string { return filepath.Join(v.dir, "vault.enc") }
|
||||
|
||||
// load 读取并解密全部条目
|
||||
func (v *Vault) load() (map[string]string, error) {
|
||||
raw, err := os.ReadFile(v.path())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var f vaultFile
|
||||
if err = json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Entries == nil {
|
||||
f.Entries = map[string]string{}
|
||||
}
|
||||
return f.Entries, nil
|
||||
}
|
||||
|
||||
// Save 加密保存单条凭据(cookie/token 等,值为 JSON 字符串)
|
||||
func (v *Vault) Save(accountID string, plaintext []byte) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
entries[accountID] = base64.StdEncoding.EncodeToString(sealed)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
// Load 解密读取单条凭据
|
||||
func (v *Vault) Load(accountID string) ([]byte, error) {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealedB64, ok := entries[accountID]
|
||||
if !ok {
|
||||
return nil, errors.New("entry not found")
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(sealedB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sealed) < gcm.NonceSize() {
|
||||
return nil, errors.New("entry corrupted")
|
||||
}
|
||||
nonce, ciphertext := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():]
|
||||
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||
}
|
||||
|
||||
// Delete 删除单条凭据
|
||||
func (v *Vault) Delete(accountID string) error {
|
||||
entries, err := v.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(entries, accountID)
|
||||
return v.persist(entries)
|
||||
}
|
||||
|
||||
func (v *Vault) persist(entries map[string]string) error {
|
||||
raw, err := json.MarshalIndent(vaultFile{Version: 1, Entries: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(v.path(), raw, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
secret := []byte(`{"cookies":"session=abc","token":"xyz"}`)
|
||||
if err = v.Save("acc-1", secret); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
// 文件不应包含明文
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "vault.enc"))
|
||||
if stringContains(string(raw), "session=abc") {
|
||||
t.Fatal("vault file must not contain plaintext")
|
||||
}
|
||||
got, err := v.Load("acc-1")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if string(got) != string(secret) {
|
||||
t.Fatalf("roundtrip mismatch: %s vs %s", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1, _ := Open(dir)
|
||||
_ = v1.Save("acc-2", []byte("data-2"))
|
||||
v2, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got, err := v2.Load("acc-2")
|
||||
if err != nil || string(got) != "data-2" {
|
||||
t.Fatalf("reopen mismatch: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-3", []byte("data-3"))
|
||||
path := filepath.Join(dir, "vault.enc")
|
||||
raw, _ := os.ReadFile(path)
|
||||
raw[len(raw)/2] ^= 0xff
|
||||
_ = os.WriteFile(path, raw, 0o600)
|
||||
if _, err := v.Load("acc-3"); err == nil {
|
||||
t.Fatal("tampered ciphertext should fail auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v, _ := Open(dir)
|
||||
_ = v.Save("acc-4", []byte("d4"))
|
||||
if err := v.Delete("acc-4"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := v.Load("acc-4"); err == nil {
|
||||
t.Fatal("deleted entry should not load")
|
||||
}
|
||||
}
|
||||
|
||||
func stringContains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && indexOf(s, sub) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user