從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
8.4 KiB
Go
260 lines
8.4 KiB
Go
// encrypted_file_tokenstore.go — AES-GCM encrypted file TokenStore(AB7 範圍)。
|
||
//
|
||
// 實作 ADR-009 的 Phase 0「encrypted file + machineID 衍生 passphrase」策略,
|
||
// 取代 AB5 留下的 MemoryTokenStore。
|
||
//
|
||
// 設計要點:
|
||
//
|
||
// - passphrase 衍生:scrypt(machineID || fallbackSalt, salt, N=1<<15, r=8, p=1, keyLen=32)
|
||
// - 檔案格式:[salt(16)][nonce(12)][ciphertext+GCM tag]
|
||
// salt 每次 Save 都重抽,讓同 token 的兩次 Save 產生不同密文。
|
||
// - machineID 取不到時 fallback 到 `<dataDir>/.salt`(首次啟動隨機寫入),並在
|
||
// 建構時印一行 WARN log。fallback 的 salt 本身不是秘密,但能讓 passphrase
|
||
// 不只來自空字串。
|
||
// - Save(""), Delete() 語義:removes the file(Load() 回 ("", nil))。
|
||
// - tamper 偵測:GCM auth tag 自帶,解密失敗回 ErrTokenCorrupted。
|
||
//
|
||
// 非目標(Phase 1 才做):
|
||
// - OS keychain(macOS Keychain / Windows DPAPI / Linux Secret Service)
|
||
// - file locking 對抗多 process 並發(單 instance lock 已在 App 層處理)
|
||
// - key rotation
|
||
|
||
package tunnel
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
|
||
"golang.org/x/crypto/scrypt"
|
||
)
|
||
|
||
const (
|
||
// tokenFileName 是 encrypted token 檔名(放在 dataDir 下)。
|
||
tokenFileName = "token.bin"
|
||
|
||
// saltFallbackFileName 是 machineID 取不到時的 fallback salt。
|
||
saltFallbackFileName = ".salt"
|
||
|
||
// tokenSaltLen 是每次加密抽的 salt 長度(16 bytes,scrypt 建議 >= 8)。
|
||
tokenSaltLen = 16
|
||
|
||
// tokenNonceLen 是 AES-GCM nonce 長度(固定 12)。
|
||
tokenNonceLen = 12
|
||
|
||
// tokenKeyLen 是衍生出的 AES-256 key 長度。
|
||
tokenKeyLen = 32
|
||
|
||
// scryptN / scryptR / scryptP:scrypt 參數(符合 OWASP 2023 建議下限)。
|
||
// N=1<<15 在一般機器約 30-50ms,使用者感知不到(只在 Load/Save 時跑)。
|
||
scryptN = 1 << 15
|
||
scryptR = 8
|
||
scryptP = 1
|
||
)
|
||
|
||
// ErrTokenCorrupted 表示 token 檔內容解密失敗(machineID 改了、檔案被竄改、
|
||
// 或被別台機器的 encrypted file 取代)。呼叫端應提示使用者重新配對。
|
||
var ErrTokenCorrupted = errors.New("tunnel: token file corrupted or from different machine")
|
||
|
||
// EncryptedFileTokenStore 以 AES-256-GCM 加密儲存 Session Token。
|
||
//
|
||
// 執行緒安全:內含 sync.RWMutex;Save / Delete 互斥,Load 允許並發。
|
||
type EncryptedFileTokenStore struct {
|
||
mu sync.RWMutex
|
||
|
||
// path 是 token 檔的絕對路徑(例:<dataDir>/token.bin)。
|
||
path string
|
||
|
||
// fallbackSaltPath 在 machineID 取不到時使用。
|
||
fallbackSaltPath string
|
||
|
||
// passphrase 是 scrypt 派生前的原料(machineID 或 fallback salt 的位元串)。
|
||
passphrase []byte
|
||
|
||
// logger 用於印 fallback 警告;nil 時走 log.Default()。
|
||
logger Logger
|
||
}
|
||
|
||
// NewEncryptedFileTokenStore 建立 encrypted file store。
|
||
//
|
||
// dataDir 是 Agent 資料目錄(例:`~/Library/Application Support/visiona-agent`)。
|
||
// 建構過程不讀檔:只決定 passphrase 來源 + 在 machineID 失敗時寫 fallback salt。
|
||
//
|
||
// logger 可為 nil;nil 時使用 log.Default()。
|
||
func NewEncryptedFileTokenStore(dataDir string, logger Logger) (*EncryptedFileTokenStore, error) {
|
||
if dataDir == "" {
|
||
return nil, errors.New("tunnel: NewEncryptedFileTokenStore requires non-empty dataDir")
|
||
}
|
||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||
return nil, fmt.Errorf("tunnel: cannot create dataDir %q: %w", dataDir, err)
|
||
}
|
||
|
||
store := &EncryptedFileTokenStore{
|
||
path: filepath.Join(dataDir, tokenFileName),
|
||
fallbackSaltPath: filepath.Join(dataDir, saltFallbackFileName),
|
||
logger: logger,
|
||
}
|
||
|
||
// 決定 passphrase 來源
|
||
if id, err := readMachineID(); err == nil && id != "" {
|
||
store.passphrase = []byte(id)
|
||
} else {
|
||
store.logf("WARN: machineID 無法取得(%v),fallback 使用本機隨機 salt。備份到其他機器後 token 將無法 decrypt。", err)
|
||
salt, sErr := store.ensureFallbackSalt()
|
||
if sErr != nil {
|
||
return nil, fmt.Errorf("tunnel: fallback salt setup failed: %w", sErr)
|
||
}
|
||
store.passphrase = salt
|
||
}
|
||
|
||
return store, nil
|
||
}
|
||
|
||
// Save 加密寫入 token;空字串視為 Delete。寫檔採 atomic rename。
|
||
func (s *EncryptedFileTokenStore) Save(token string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
if token == "" {
|
||
return s.deleteLocked()
|
||
}
|
||
|
||
// 1. 每次 Save 都重抽 salt + nonce
|
||
salt := make([]byte, tokenSaltLen)
|
||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||
return fmt.Errorf("tunnel: generate salt: %w", err)
|
||
}
|
||
nonce := make([]byte, tokenNonceLen)
|
||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||
return fmt.Errorf("tunnel: generate nonce: %w", err)
|
||
}
|
||
|
||
// 2. scrypt 派生 AES-256 key
|
||
key, err := scrypt.Key(s.passphrase, salt, scryptN, scryptR, scryptP, tokenKeyLen)
|
||
if err != nil {
|
||
return fmt.Errorf("tunnel: scrypt key derivation: %w", err)
|
||
}
|
||
|
||
// 3. AES-GCM 加密
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return fmt.Errorf("tunnel: new cipher: %w", err)
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return fmt.Errorf("tunnel: new gcm: %w", err)
|
||
}
|
||
ciphertext := gcm.Seal(nil, nonce, []byte(token), nil)
|
||
|
||
// 4. 組合檔案內容 [salt(16)][nonce(12)][ciphertext]
|
||
buf := make([]byte, 0, tokenSaltLen+tokenNonceLen+len(ciphertext))
|
||
buf = append(buf, salt...)
|
||
buf = append(buf, nonce...)
|
||
buf = append(buf, ciphertext...)
|
||
|
||
// 5. atomic write-rename
|
||
tmp := s.path + ".tmp"
|
||
if err := os.WriteFile(tmp, buf, 0o600); err != nil {
|
||
return fmt.Errorf("tunnel: write tmp token file: %w", err)
|
||
}
|
||
if err := os.Rename(tmp, s.path); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("tunnel: rename tmp token file: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Load 讀取並解密 token;檔案不存在回 ("", nil)。
|
||
// 解密失敗(key 不對 / 檔案被竄改)回 ("", ErrTokenCorrupted)。
|
||
func (s *EncryptedFileTokenStore) Load() (string, error) {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
data, err := os.ReadFile(s.path)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return "", nil
|
||
}
|
||
return "", fmt.Errorf("tunnel: read token file: %w", err)
|
||
}
|
||
if len(data) < tokenSaltLen+tokenNonceLen+16 { // GCM 至少多 16 bytes tag
|
||
return "", ErrTokenCorrupted
|
||
}
|
||
|
||
salt := data[:tokenSaltLen]
|
||
nonce := data[tokenSaltLen : tokenSaltLen+tokenNonceLen]
|
||
ciphertext := data[tokenSaltLen+tokenNonceLen:]
|
||
|
||
key, err := scrypt.Key(s.passphrase, salt, scryptN, scryptR, scryptP, tokenKeyLen)
|
||
if err != nil {
|
||
return "", fmt.Errorf("tunnel: scrypt key derivation: %w", err)
|
||
}
|
||
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return "", fmt.Errorf("tunnel: new cipher: %w", err)
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return "", fmt.Errorf("tunnel: new gcm: %w", err)
|
||
}
|
||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||
if err != nil {
|
||
return "", ErrTokenCorrupted
|
||
}
|
||
return string(plaintext), nil
|
||
}
|
||
|
||
// Delete 移除 token 檔;不存在視為成功(idempotent)。
|
||
func (s *EncryptedFileTokenStore) Delete() error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.deleteLocked()
|
||
}
|
||
|
||
// deleteLocked 在持有 s.mu 時呼叫。
|
||
func (s *EncryptedFileTokenStore) deleteLocked() error {
|
||
if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return fmt.Errorf("tunnel: remove token file: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ensureFallbackSalt 讀取或建立 fallback salt 檔。
|
||
// 回傳的 salt 會當 passphrase 原料,因此 Save/Load 之間必須一致。
|
||
func (s *EncryptedFileTokenStore) ensureFallbackSalt() ([]byte, error) {
|
||
data, err := os.ReadFile(s.fallbackSaltPath)
|
||
if err == nil && len(data) >= tokenSaltLen {
|
||
return data, nil
|
||
}
|
||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
// 讀得到但內容壞;視為要重新產(也會覆蓋壞檔)
|
||
s.logf("WARN: fallback salt read failed (%v), regenerating", err)
|
||
}
|
||
|
||
salt := make([]byte, 32) // 比 tokenSaltLen 稍大,作 passphrase 用更穩
|
||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||
return nil, fmt.Errorf("generate fallback salt: %w", err)
|
||
}
|
||
if err := os.WriteFile(s.fallbackSaltPath, salt, 0o600); err != nil {
|
||
return nil, fmt.Errorf("write fallback salt: %w", err)
|
||
}
|
||
return salt, nil
|
||
}
|
||
|
||
// logf 用 configured logger 或 log.Default() 印一行。
|
||
func (s *EncryptedFileTokenStore) logf(format string, args ...any) {
|
||
if s.logger != nil {
|
||
s.logger.Printf("[tokenstore] "+format, args...)
|
||
return
|
||
}
|
||
log.Printf("[tokenstore] "+format, args...)
|
||
}
|