依 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)。
192 lines
11 KiB
Markdown
192 lines
11 KiB
Markdown
# 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 = <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 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)
|