visionA/docs/autoflow/04-architecture/adr/adr-003-pairing-token.md
jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類
共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git),
讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等
per-branch 筆記。

- 02-prd/        21 個檔(PRD、features、market-analysis 等)
- 03-design/     18 個檔(design-spec、wireframes、flows 等)
- 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等)
- 07-delivery/   3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup)

合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv,
但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
2026-05-04 16:55:55 +08:00

11 KiB
Raw Blame History

ADR-003以 Pairing Token 取代 POC 的 SHA256(MAC) Token

狀態

Accepted — 2026-04-212026-04-22 修訂:明確兩階段 Token 流程 + 固定格式)

背景 (Context)

POC 的 tunnel 認證機制非常簡單:

  • local agent 啟動時,計算 SHA256(MAC address)[:16],得到 16 字元 hex 當 token
  • ?token=xxxX-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 TokenvAc_ + 32 字元 hex          (總長 36 字元Admin-Credential
Session TokenvAs_ + 64 字元 hex          (總長 68 字元Agent-Session
  • 字元集:[0-9a-f] 小寫 hex前綴 vAc_ / vAs_
  • 產生方式:crypto/rand.Read(16 bytes)hex.EncodeToString(...)pairingcrypto/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 tokenvAc_ + 32 hex
    • 寫入 DBtoken_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可撤銷

  1. local agent 首次用 Pairing Token 連 WS /tunnel/connect?token=vAc_xxx
  2. remote-proxy(透過 api-server 內部呼叫)處理:
    • 查 DBtoken 是否 kind='pairing' 且未撤銷、未過期、未使用
    • 有效 → 原子升級
      • 產生 Session TokenvAs_ + 64 hex
      • 寫入新 DB rowkind='session', parent_token = <pairing token_hash>, device_id = <new or resolved>, expires_at = now + 90d
      • 將 Pairing Token 標記 used_at = now()(作廢;無法再次升級)
      • 在 WS upgrade response header首個 yamux control frame 回傳 Session Token 明文
    • 建立 yamux sessionSessionStore.Register(session_token_hash, handle)
    • 無效 → 拒絕並回 HTTP 401
  3. local agent 收到 Session Token 後持久化儲存(取代 Pairing Token之後重連一律用 Session Token
  4. 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 ──────│
  1. 使用者可在「Devices」頁面 list / revoke 任一 device 的 Session TokenUPDATE revoked_at

雛形版(當前要做的)

雛形不接 DB / 不接 Auth,但要把介面切好,未來替換實作即可:

// 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_idVISIONA_PAIRING_TOKEN=abc123...VISIONA_PAIRING_USER_ID=demo-user
  • Validate 簡單比對;Create/Revoke/ListErrNotImplemented
  • 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 hexagent 直接用它連 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 需外部 IdPAuth0 / 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 logPhase 1 要記到 audit log

風險

  • Token 儲存明文 vs hash:產品版要把 token_hash = sha256(token) 存 DB不存明文。雛形 env 暫用明文
  • Token 透過 URL query 傳遞會進 access logPOC 已這樣做雛形保留Phase 1 評估改用 Authorization: Bearer xxx headerWebSocket 自訂 header 瀏覽器有限制,但 local agent 可以)
  • 如果使用者把 token 貼到 public repo:產品版需支援「自動撤銷洩漏 token」secret scanning雛形不處理

合規性

  • Architect 確認
  • Security ReviewPhase 1 前必做
  • 加入 .gitignore 保證 token 不進 Git工程師實作時落實

相關文件

  • Design Doc §6安全架構
  • TDD §5Pairing Token 協定)
  • TDD security.md
  • 相關 ADRADR-002Tunnel Protocol、ADR-005雛形不接 DB/Auth