package handlers import ( "crypto/sha256" "encoding/hex" "log" "net/http" "strings" "time" "visiona-agent/server/internal/device" "github.com/gin-gonic/gin" ) // LocalSerialSalt 是 serial 雜湊的固定公開常數 salt(ADR-019 §2.3 / api-spec §6.3 議題 1 裁決)。 // // 刻意「公開、固定、前後端共用寫死」——非 server 私有隨機值。 // 目的是讓前端能用 Web Crypto 獨立重算 SHA-256("visiona-local-v1" || serial) 比對, // 而非「防暴力還原」(序號熵低、salt 公開時仍可枚舉回推,security 判定與威脅相稱)。 // // 明確不要做:不得用 crypto/rand 私有 salt(前端算不出);不得 per-request 隨機 salt。 const LocalSerialSalt = "visiona-local-v1" // deviceLister 抽象 device.Manager 的 ListDevices,方便測試注入。 type deviceLister interface { ListDevices() []deviceInfoView } // deviceInfoView 是 hello 需要的最小 device 視圖(只要 serial)。 type deviceInfoView struct { SerialNumber string } // managerAdapter 把 *device.Manager 轉成 deviceLister。 type managerAdapter struct { mgr *device.Manager } func (a managerAdapter) ListDevices() []deviceInfoView { infos := a.mgr.ListDevices() out := make([]deviceInfoView, 0, len(infos)) for _, info := range infos { out = append(out, deviceInfoView{SerialNumber: info.SerialNumber}) } return out } // fakeSerialNumber 是 pyusb-fallback placeholder,代表「沒有真實序號」。 // 與 device 套件保持一致(device.manager.go),不對它計 hash(無意義且會洩漏 placeholder)。 const fakeSerialNumber = "0x00000000" // LocalHandler 提供 ADR-019 的本機直連支援 endpoint(hello / issue-token)。 type LocalHandler struct { devices deviceLister store localTokenStore } // localTokenStore 是 LocalHandler 依賴的 token store 介面(issue 用)。 // 對應 api.TokenStore,用介面避免 handlers → api 的反向依賴。 type localTokenStore interface { Issue(deviceID string) (token string, expiresAt time.Time, err error) IsLimitErr(err error) bool } // NewLocalHandler 建立 LocalHandler。mgr 提供裝置序號、store 提供 token 發放。 func NewLocalHandler(mgr *device.Manager, store localTokenStore) *LocalHandler { return &LocalHandler{ devices: managerAdapter{mgr: mgr}, store: store, } } // hashSerial 計算 SHA-256(salt || fullSerial) 的 lowercase hex(ADR-019 §2.3)。 func hashSerial(serial string) string { sum := sha256.Sum256([]byte(LocalSerialSalt + serial)) return hex.EncodeToString(sum[:]) } // Hello 是 GET /api/local/hello — 同機偵測 + 身分驗證(bootstrap,無 token)。 // // 回傳(最小揭露,api-spec §6.3): // - serialHashes:每個 = SHA-256("visiona-local-v1" || fullSerial) hex // - supportsLocalUpload:布林 true // // 明確不回:agentVersion、完整 serial、deviceId、機器名、任何其他欄位。 func (h *LocalHandler) Hello(c *gin.Context) { infos := h.devices.ListDevices() hashes := make([]string, 0, len(infos)) for _, info := range infos { serial := strings.TrimSpace(info.SerialNumber) // 跳過空 / fake placeholder 序號——無真實身分、hash 它只會洩漏 placeholder。 if serial == "" || strings.EqualFold(serial, fakeSerialNumber) { continue } hashes = append(hashes, hashSerial(serial)) } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "serialHashes": hashes, "supportsLocalUpload": true, }, }) } // issueTokenRequest 是 issue-token 的請求 body。 type issueTokenRequest struct { Serial string `json:"serial"` } // IssueToken 是 POST /api/local/issue-token — 產 one-time upload token。 // // 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(受 HostGuard 約束 = loopback)。 // 產出的 token 綁 deviceId(此處 = serial)+ one-time + 120s TTL。 // 達 32 上限 → 429 LOCAL_TOKEN_LIMIT。 // // 稽核 log:記 deviceId + 成功/失敗,絕不 log token 明文。 func (h *LocalHandler) IssueToken(c *gin.Context) { var req issueTokenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{ "code": "BAD_REQUEST", "message": "serial is required", }}) return } serial := strings.TrimSpace(req.Serial) if serial == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{ "code": "BAD_REQUEST", "message": "serial is required", }}) return } token, expiresAt, err := h.store.Issue(serial) if err != nil { if h.store.IsLimitErr(err) { // 稽核:達上限(不含 token)。 log.Printf("[local-token] issue REJECTED (limit) deviceId=%s ts=%s", serial, time.Now().UTC().Format(time.RFC3339)) c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": gin.H{ "code": "LOCAL_TOKEN_LIMIT", "message": "too many unused upload tokens", }}) return } log.Printf("[local-token] issue ERROR deviceId=%s ts=%s err=%v", serial, time.Now().UTC().Format(time.RFC3339), err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{ "code": "INTERNAL_ERROR", "message": "failed to issue token", }}) return } // 稽核:發放成功(deviceId + 時間,絕不記 token 明文)。 log.Printf("[local-token] issue OK deviceId=%s ts=%s", serial, time.Now().UTC().Format(time.RFC3339)) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "token": token, "expiresAt": expiresAt.UnixMilli(), "ttlSeconds": 120, }, }) }