visionA/local-agent/visiona-agent/internal/tunnel/encrypted_file_tokenstore.go
jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 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>
2026-05-01 11:22:01 +08:00

260 lines
8.4 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.

// encrypted_file_tokenstore.go — AES-GCM encrypted file TokenStoreAB7 範圍)。
//
// 實作 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 fileLoad() 回 ("", nil))。
// - tamper 偵測GCM auth tag 自帶,解密失敗回 ErrTokenCorrupted。
//
// 非目標Phase 1 才做):
// - OS keychainmacOS 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 bytesscrypt 建議 >= 8
tokenSaltLen = 16
// tokenNonceLen 是 AES-GCM nonce 長度(固定 12
tokenNonceLen = 12
// tokenKeyLen 是衍生出的 AES-256 key 長度。
tokenKeyLen = 32
// scryptN / scryptR / scryptPscrypt 參數(符合 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.RWMutexSave / 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 可為 nilnil 時使用 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 無法取得(%vfallback 使用本機隨機 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...)
}