// 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 到 `/.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 檔的絕對路徑(例:/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...) }