# ADR-003:以 Pairing Token 取代 POC 的 SHA256(MAC) Token ## 狀態 Accepted — 2026-04-21(2026-04-22 修訂:明確兩階段 Token 流程 + 固定格式) ## 背景 (Context) POC 的 tunnel 認證機制非常簡單: - local agent 啟動時,計算 `SHA256(MAC address)[:16]`,得到 16 字元 hex 當 token - 用 `?token=xxx` 或 `X-Relay-Token` header 連線 relay - relay 以 `map[token]*yamux.Session` 記憶體儲存,**無任何驗證**——任何人拿到 token 就能連上並註冊 session 這個設計在 POC 階段可接受,但不適合正式產品: | POC 現況 | 問題 | |---------|------| | Token = MAC 衍生值,**永不變** | 洩漏後無法撤銷;換電腦 token 也跟著換(身分無法追蹤) | | 無 expiry | 一次洩漏,永久曝險 | | 無 ownership | Token 沒綁到「使用者」概念,無法實現「我的裝置列表」 | | 無 revoke | 沒有「登出所有裝置」能力 | | 無 rotation | 無法定期輪換降低風險 | | Token == Identity | Token 本身即是身分,無法與 User Account 解耦 | 正式產品的雲端版需要: - 綁定到「某個使用者帳號」 - 可撤銷 - 有 expiry,過期要 re-pair - 可同一使用者有多台裝置(每台一個獨立 token) - 可由使用者介面 list / revoke ## 決策 (Decision) 引入 **兩階段 Token** 機制:**Pairing Token(短期,一次性)** → 換取 **Session Token(長期,可撤銷)**。其生命週期如下: ### Token 格式(統一規範,2026-04-22 修訂 M-1) ``` Pairing Token:vAc_ + 32 字元 hex (總長 36 字元;Admin-Credential) Session Token:vAs_ + 64 字元 hex (總長 68 字元;Agent-Session) ``` - 字元集:`[0-9a-f]` 小寫 hex,前綴 `vAc_` / `vAs_` - 產生方式:`crypto/rand.Read(16 bytes)` → `hex.EncodeToString(...)`(pairing);`crypto/rand.Read(32 bytes)`(session) - **API 回傳值永遠是純字串,無空格、無分隔符** - UI 顯示層可視情況每 8 字元插入空格提升可讀性(例:`vAc_a1b2c3d4 e5f6g7h8 ...`) - 使用者貼上時前端正規化 `.replace(/\s/g, '')` - 前綴讓 log / debug 一眼看出類型;正則驗證:`^vAc_[0-9a-f]{32}$` / `^vAs_[0-9a-f]{64}$` ### 兩階段流程(Phase 1 完整;雛形簡化) #### Stage 1 — Pairing Token(短期,一次性,15 分鐘 TTL) 1. 使用者在雲端 Web 登入(`visionA-frontend`) 2. 進入「Devices → Pair New Device」→ 前端呼叫 `POST /api/pairing/token` 3. `api-server` 產生 pairing token(`vAc_` + 32 hex): - 寫入 DB:`token_hash = sha256(plain)`, `kind='pairing'`, `expires_at = now + 15min`, `used_at = NULL` - 回傳 token 明文(**只在此次回應中出現一次**) 4. 使用者把 token 貼到 local agent 的設定 UI / config / CLI flag 5. local agent 持有此 **Pairing Token**,準備建立第一次連線 #### Stage 2 — Session Token(長期,90 天 TTL,可撤銷) 6. local agent 首次用 Pairing Token 連 `WS /tunnel/connect?token=vAc_xxx` 7. `remote-proxy`(透過 `api-server` 內部呼叫)處理: - 查 DB:token 是否 `kind='pairing'` 且未撤銷、未過期、未使用 - 有效 → **原子升級**: - 產生 Session Token(`vAs_` + 64 hex) - 寫入新 DB row:`kind='session'`, `parent_token = `, `device_id = `, `expires_at = now + 90d` - 將 Pairing Token 標記 `used_at = now()`(作廢;無法再次升級) - **在 WS upgrade response header** 或 **首個 yamux control frame** 回傳 Session Token 明文 - 建立 yamux session,`SessionStore.Register(session_token_hash, handle)` - 無效 → 拒絕並回 HTTP 401 8. local agent 收到 Session Token 後**持久化儲存**(取代 Pairing Token),之後重連一律用 Session Token 9. Session Token 過期、被撤銷 → local agent 連線被拒 → 使用者需在 Web UI 重新發 Pairing Token 走一次 Stage 1 #### 狀態轉換時序(Phase 1) ``` 使用者 Web UI api-server local agent remote-proxy / DB │ │ │ │ │─ Pair New Device ────►│ │ │ │ │─ INSERT pairing ──►│ │ │ │ kind='pairing' │ │ │ │ TTL 15 min │ │ │◄── vAc_xxx (once) ────│ │ │ │ │ │─ 複製貼到 agent ────────────────────────────►│ │ │ │─ WS /tunnel/connect ─►│ │ │ ?token=vAc_xxx │ │ │ │─ Validate(pairing) │ │ │─ 產生 vAs_yyy │ │ │─ INSERT session │ │ │ kind='session' │ │ │ TTL 90 day │ │ │─ mark pairing used │ │◄─ upgrade + vAs_yyy ──│ │ │─ 持久化 vAs_yyy │ │ │ │ │ │─ 日後重連(always) ──►│ │ │ ?token=vAs_yyy │ │ │◄── 直接接受 ──────────│ │ │ │ │─ Revoke device ──────►│─ UPDATE session ──────────────────────────►│ │ │ revoked_at = now │ │ │◄── 下次連線 401 ──────│ ``` 10. 使用者可在「Devices」頁面 list / revoke 任一 device 的 Session Token(`UPDATE revoked_at`) ### 雛形版(當前要做的) 雛形**不接 DB / 不接 Auth**,但**要把介面切好**,未來替換實作即可: ```go // internal/auth/pairing.go type PairingStore interface { Validate(ctx, token) (*PairingInfo, error) MarkUsed(ctx, token, deviceID) error Create(ctx, userID) (string, *PairingInfo, error) Revoke(ctx, token) error List(ctx, userID) ([]*PairingInfo, error) } type PairingInfo struct { TokenHash string UserID string DeviceID string CreatedAt time.Time ExpiresAt *time.Time UsedAt *time.Time RevokedAt *time.Time } ``` **雛形實作 `StaticPairingStore`**: - 從 env var 讀一組寫死的 token + 一個假 user_id(例:`VISIONA_PAIRING_TOKEN=abc123...`,`VISIONA_PAIRING_USER_ID=demo-user`) - `Validate` 簡單比對;`Create/Revoke/List` 回 `ErrNotImplemented` - local agent 啟動 config 寫這個 token(或 CLI flag `--pairing-token=xxx`) 未來替換為 `DBPairingStore`,其他程式碼(relay session、API handler)完全不改。 ### 雛形 vs Phase 1 的單/雙階段處理(2026-04-22 修訂 M-2) | 階段 | 做法 | |------|------| | **雛形(Phase 0)** | 為簡化開發,**以單一 Pairing Token 形式比對**,不做升級步驟。`StaticPairingStore` 讀取 `VISIONA_PAIRING_TOKEN` env(格式仍為 `vAc_` + 32 hex),agent 直接用它連 tunnel。**無 TTL、無升級、無撤銷**。這是刻意妥協以加速驗證。 | | **Phase 1** | **完整兩階段**(見「兩階段流程」):Pairing Token 15 min TTL + Session Token 90 d TTL + 撤銷機制 | **雛形限制須明確告知使用者**:雛形的單一 token 等同「永不過期的 pairing + session 合併體」,僅適合 dev 環境。Phase 1 前**必須**切到兩階段。 ## 考慮過的替代方案 | 方案 | 優點 | 缺點 | 排除原因 | |------|------|------|---------| | **沿用 SHA256(MAC)** | POC 已驗證 | 不安全、不可撤銷、無 ownership | 無法做成產品 | | **mTLS 憑證(client certificate)** | 業界標準、極安全 | 憑證管理複雜、使用者端安裝門檻高、Web 產生憑證 UX 差 | 使用者體驗門檻過高 | | **OAuth Device Flow** | 標準化、適合 headless | 需外部 IdP(Auth0 / Cognito)、實作複雜 | Phase 2 可以評估,雛形過度複雜 | | **短期 JWT + refresh token** | 標準做法 | JWT 無法撤銷(除非建黑名單);refresh token 又變另一個長期 token | 仍需處理 revoke,不如直接用 opaque token | | **使用者密碼 / API key** | 簡單 | 無法限定 scope、難以細粒度撤銷 | 粗糙 | ## 後果 (Consequences) ### 正面影響 - **抽象清晰**:`PairingStore` interface 把 token 驗證與 tunnel session 管理解耦 - **雛形到 Phase 1 零程式碼修改**:只換 store 實作 - **未來支援多種 token 形態**:無論是 JWT、opaque token、還是 DB-backed session token,都實作同一個 interface ### 負面影響(接受的取捨) - **雛形安全性差**:env 寫死 token,任何能讀 env 的人都能冒充。可以接受,因為雛形只會在開發環境跑 - **沒有 rate limit**:雛形不做;Phase 1 要做 `/tunnel/connect` 的 IP / token rate limit - **無 logging of failed attempts**:雛形只記 error log;Phase 1 要記到 audit log ### 風險 - **Token 儲存明文 vs hash**:產品版要把 `token_hash = sha256(token)` 存 DB,不存明文。雛形 env 暫用明文 - **Token 透過 URL query 傳遞會進 access log**:POC 已這樣做;雛形保留;Phase 1 評估改用 `Authorization: Bearer xxx` header(WebSocket 自訂 header 瀏覽器有限制,但 local agent 可以) - **如果使用者把 token 貼到 public repo**:產品版需支援「自動撤銷洩漏 token」(secret scanning);雛形不處理 ## 合規性 - [x] Architect 確認 - [ ] Security Review:Phase 1 前必做 - [ ] 加入 `.gitignore` 保證 token 不進 Git:工程師實作時落實 ## 相關文件 - Design Doc §6(安全架構) - TDD §5(Pairing Token 協定) - TDD `security.md` - 相關 ADR:ADR-002(Tunnel Protocol)、ADR-005(雛形不接 DB/Auth)