# Security — 安全考量與 Pairing Token 協定 > 本文件彙整所有安全相關細節。部分對應 Design Doc §6。 --- ## 1. Pairing Token 協定 {#pairing-token} ### 1.1 雛形版(v0.1) 最簡化單一 token: ```bash [開發者 setup] # 產生符合正式格式的雛形 token:vAc_ + 32 hex export VISIONA_PAIRING_TOKEN="vAc_$(openssl rand -hex 16)" export VISIONA_STATIC_USER_ID=demo-user [local agent] 啟動時讀同一個 env(或 config 檔 / CLI flag) 連 ws://proxy:3800/tunnel/connect?token=$VISIONA_PAIRING_TOKEN [remote-proxy] PairingStore.Validate(token): if token == env.VISIONA_PAIRING_TOKEN: return {UserID: env.VISIONA_STATIC_USER_ID, TokenHash: sha256hex(token)} else: return ErrInvalidToken ``` **雛形限制**: - 單 token 單 user;永不過期;無法 revoke - **不做 Stage 2 升級**(pairing 和 session 合併為同一個 token,端點收到任何 `vAc_*` 格式就接受) - 只適合 dev;Phase 1 切入正式 DB-backed 前,必須先改為 Stage 1 + Stage 2 兩階段(見 §1.2、ADR-003) ### 1.2 Phase 1:DB-backed + 兩階段 Token 詳見 ADR-003。雛形 → Phase 1 流程定義如下: #### Stage 1 — Pairing Token(短期,**15 分鐘 TTL**,一次性) 使用者於 Web 頁面點「Pair New Device」: ``` POST /api/pairing/token Auth: bearer Response 200: { "token": "vAc_a1b2c3d4e5f6...", // 明文只此一次(vAc_ + 32 hex,共 36 字元) "expires_at": "2026-04-21T13:15:00Z" // 15 分鐘後過期 } DB 寫入 pairing_tokens: token_hash = sha256(plain) user_id = kind = 'pairing' expires_at = now + 15min used_at = NULL ``` 使用者把 token 貼到 local agent(複製貼上、QR code、CLI flag 皆可)。 #### Stage 2 — Session Token(長期,**90 天 TTL**,可撤銷) local agent 首次用 Pairing Token 連 proxy,**遠端會換發** Session Token: ``` WS /tunnel/connect?token=vAc_a1b2c3d4... remote-proxy (經 PairingStore.Validate): info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1) AND kind = 'pairing' AND revoked_at IS NULL AND expires_at > now() AND used_at IS NULL IF info NOT FOUND → reject 401 # 原子升級 (single transaction) BEGIN; device_id := create_device_if_needed(info.user_id, agent_serial) session_plain := "vAs_" + hex(crypto/rand(32)) # 64 hex chars INSERT INTO pairing_tokens (token_hash, user_id, device_id, kind, parent_token, expires_at, created_at) VALUES (sha256(session_plain), info.user_id, device_id, 'session', info.token_hash, now() + 90 days, now()); UPDATE pairing_tokens SET used_at = now(), device_id = $device_id WHERE token_hash = info.token_hash; COMMIT; # 在 WS upgrade response header 或首個 yamux control frame 回傳: # { "session_token": "vAs_..." } accept tunnel ``` local agent 收到後**持久化 Session Token**(config / keychain),**丟棄 Pairing Token**(已作廢)。日後重連一律用 Session Token;`kind='session'` 的 token 重連只需: ``` info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1) AND kind = 'session' AND revoked_at IS NULL AND expires_at > now() IF info NOT FOUND → reject 401(使用者需在 Web UI 重 Pair) ELSE → accept tunnel + UPDATE last_seen_at ``` **撤銷**:使用者在 Web UI「Devices → Revoke」→ `UPDATE pairing_tokens SET revoked_at = now() WHERE token_hash = ?`。下次連線驗證即被拒。 ### 1.3 Token 格式(2026-04-22 M-1 修訂:統一為 hex + 前綴) ``` vAc_[0-9a-f]{32} # Admin-Credential(pairing),共 36 字元 vAs_[0-9a-f]{64} # Agent-Session(session),共 68 字元 ``` - 字元集:**小寫 hex + 底線前綴**(不用 base64url,避免 URL-safe 混淆 / 大小寫歧義) - 產生:`crypto/rand.Read` → `hex.EncodeToString` - **API 回傳純字串,無空格、無分隔符**(前端 UI 顯示時可每 8 字元插空格提升可讀性;複製/貼上時前端需 `.replace(/\s/g, '')` 正規化) - 正則驗證: - Pairing:`^vAc_[0-9a-f]{32}$` - Session:`^vAs_[0-9a-f]{64}$` - 前綴讓 log / debug 一眼看出類型;log 永遠只記前 8 字元前綴(例:`vAc_a1b2c3d4...`) ### 1.4 Hand-shake 額外欄位(Phase 1) WS upgrade 前,local agent 可帶附加 header 宣告自己: ``` GET /tunnel/connect?token=vAc_a1b2c3d4... HTTP/1.1 X-Agent-Version: local-tool 1.2.3 X-Agent-OS: darwin/arm64 X-Agent-Serial: KL520-1234-ABCD ``` proxy 驗證 + 記錄到 `pairing_tokens.device_id` 綁定。 --- ## 2. Auth(使用者登入) ### 2.0 介面雙層(2026-04-22 新增 M-3) Auth 切分為兩層 interface,對應不同生命週期 / 呼叫場景: | 介面 | 層級 | 呼叫時機 | 方法 | |------|------|---------|------| | `AuthService` | **Middleware 層** | 每個 HTTP request 進來時(middleware)| `Authenticate(req) → UserContext`、`Authorize(ctx, resource, action)` | | `AuthProvider` | **Handler 層** | 使用者登入 / 註冊 / 登出等明確動作 | `Register`、`Login`、`Logout`、`ValidateToken`、`GetUser` | 雙層並存原因: - `AuthService` 著重「這個 request 代表哪位使用者」(狀態萃取),每個 request 都要跑 - `AuthProvider` 著重「使用者的 Auth 生命週期管理」(帳號 CRUD + token 簽發),只在登入登出等動作發生 實作時通常一個底層 provider(例:Clerk / OIDC client)會同時被 `AuthProvider` 與 `AuthService` 包裝;但 interface 分開讓 handler 不需要知道 middleware 的細節。 ```go // internal/auth/service.go(middleware 層) type AuthService interface { Authenticate(ctx context.Context, req *http.Request) (*UserContext, error) Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error } // internal/auth/provider.go(handler 層,2026-04-22 新增) type AuthProvider interface { Register(ctx context.Context, req *RegisterRequest) (*User, error) Login(ctx context.Context, req *LoginRequest) (*LoginResult, error) Logout(ctx context.Context, token string) error ValidateToken(ctx context.Context, token string) (*UserContext, error) GetUser(ctx context.Context, userID string) (*User, error) } type LoginRequest struct { Email, Password string } type LoginResult struct { User *User; AccessToken, RefreshToken string; ExpiresAt time.Time } type RegisterRequest struct { Email, Password, Name string } ``` ### 2.1 雛形 > **⚠️ 已被 OIDC 取代(2026-04-26 / OB5)** > > 本節描述的 `StaticAuthProvider` / `StaticAuthService` 已從 codebase 移除。 > Phase 0.6 起 visionA-backend 的唯一認證路徑是 OIDC(接 Innovedus Member Center), > 詳見: > - [adr-010-oidc-bff.md](./adr/adr-010-oidc-bff.md)(接入策略) > - [adr-011-supersede-adr-005.md](./adr/adr-011-supersede-adr-005.md)(推翻決策) > - [oidc-tdd.md](./oidc-tdd.md)(實作細節) > > demo-user 仍可用 — Member Center 端 seed `demo@visionA.local / demo123` 帳號。 **歷史紀錄(已過時)**: - `/api/auth/login`、`/api/auth/register` 曾由 `StaticAuthProvider` 實作: - `Login(email, password)` — **任何帳密都通過**,回 `demo-user` + 假的 access token(`demo-access-token`) - `Register(...)` — stub,回 `ErrNotImplemented`(前端顯示「即將推出」) - `Logout(...)` — stub(清前端 store 即可) - `ValidateToken("demo-access-token")` → demo-user;其他 token → `ErrInvalidToken` - `GetUser("demo-user")` → demo-user struct;其他 → `ErrNotFound` - `StaticAuthService.Authenticate` 永遠回 `demo-user`(middleware 層;與 provider 結果一致) - 前端 login 頁面可填任意帳密、點下去直接進入應用(體驗接近真實但零 DB) ### 2.2 Phase 1 選項 | 方案 | 優 | 劣 | |------|----|----| | 自建 email + password + JWT | 完全掌控 | 要自己做 email 驗證、密碼重設、暴力破解防禦 | | Clerk / Auth0 / Supabase Auth | 現成、支援社交登入 | 綁定 vendor,成本隨用戶增加 | | OIDC 自建(Keycloak / Ory Kratos)| 開源、標準 | 維運成本 | **建議**:Phase 1 初期用 Clerk(免費額度充足,支援 Google/GitHub SSO、Magic Link、MFA),成長後視成本再評估遷移。 ### 2.3 Session 機制 - JWT(含 `sub=user_id`, `exp`, `roles`)放 `Authorization: Bearer ...` header - Refresh token 存 HttpOnly cookie(避免 XSS 拿到) - Logout:清 cookie + JWT 黑名單(Redis) ### 2.4 Service-to-Service Token(Phase 0.8 啟用) > 對齊 [`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md) 與 [`conversion.md`](./conversion.md) §5。 visionA-backend 從 Phase 0.8 起,除了「user 端的 OIDC BFF flow」(§2.0),還會以**服務身份**呼叫 Innovedus Member Center / FAA / converter — 用 OAuth 2.0 `client_credentials` grant。 #### 2.4.1 流程 ``` visionA-backend ──POST {issuer}/oauth/token──► Member Center grant_type=client_credentials client_id= ← 預埋於 OIDCConfig.ServiceClientID client_secret= ← 預埋於 OIDCConfig.ServiceClientSecret scope=converter:job.write converter:job.read files:download.read files:download.delegate ◄─────── { access_token, expires_in, scope } cache(exp - 15s 內可重用) ``` #### 2.4.2 Cache 策略 - 單一 token cache 涵蓋全部 4 個 scope(MC 端發單一 token 含全部) - cache 在 visionA-backend 進程記憶體(`sync.RWMutex` 保護) - `exp - 15s` 提前重取,避免下游使用時剛好過期 - 併發保護:double-checked locking(第一次 cache miss 時其他 goroutine 等待) - 重啟即清空(in-memory,無持久化);下次需要時重新取 #### 2.4.3 失敗處理 | 場景 | 處理 | |------|------| | 4xx(client_id / scope 設定錯)| fatal:log + 5xx response 給上游 caller,**不重試** | | 5xx / network | 指數退避 max 2 次(1s, 2s)| | Token cache 命中 → 但 MC 已 invalidate(rare) | 下次 401 時 force refresh + 重打 | #### 2.4.4 Secret 管理 - `VISIONA_OIDC_SERVICE_CLIENT_SECRET` **絕不可** commit 進 git(`.gitignore` 含 `.env`) - prod 用 AWS Secrets Manager / k8s Secret 注入 - log 永遠不印完整 token;只印前 8 字元前綴(`Bearer ey1234...`) - 若 secret 洩漏:MC 端 rotate → 重新部署 visionA-backend;in-memory cache 自然失效 #### 2.4.5 Delegated Download Token 不同於上面的 service token,FAA delegated download token 是「visionA backend 用自己的 service token 跟 MC 換來、再轉發給 browser 直連 FAA 用的短期 opaque token」: ``` visionA backend ──POST /file-access/download-tokens (Bearer service-token, scope=files:download.delegate)──► MC body: { tenant_id, user_id, object_key, method:"GET", expires_in_seconds:300 } ◄─────── { token, url, expires_at } visionA backend ──回 frontend──► { download_url, expires_at } Browser ──GET /files/{key}?access_token=──► FAA │ └──► MC validate token ``` 安全特性: - TTL 短(預設 5 分鐘 / 範圍 60-900s,由 `VISIONA_FAA_DELEGATED_TTL_SECONDS` 控制) - 綁定 method=GET + 特定 object_key(FAA 拒絕其他 method 或 key) - visionA-backend 必須先做 ownership 檢查(job_id ↔ user_id mapping)才換 token - frontend **不可快取** download_url(每次「下載」按鈕重打 backend 換新 token) #### 2.4.6 Trust Boundary | 邊界 | 信任方向 | 守護機制 | |------|---------|---------| | Browser → visionA-backend | visionA-backend **不信任** browser 帶的 user_id / object_key | 一律從 OIDC cookie session 取 user_id;object_key 從內部 mapping 反查 | | visionA-backend → converter | converter **完全信任** visionA-backend 帶的 user_id | visionA-backend 是唯一灌 user_id 的點(multipart streaming 重組時黑名單 client 帶的 user_id)| | visionA-backend → FAA | FAA 不認 visionA 而是線上跟 MC validate token | service token (s2s pull) + delegated token (browser direct) 都走 MC | | visionA-backend ↔ MC | MC 用 client_secret 認 visionA 服務身份 | 標準 OAuth 2.0 client_credentials | --- ## 3. 傳輸加密 | 連線 | 加密 | 備註 | |------|------|------| | Browser → api-server | HTTPS | Phase 1;雛形 HTTP | | Browser → api-server WS | WSS | 同上 | | api-server ↔ remote-proxy(內部 HTTP)| HTTP(VPC-only) | 如 public 需 mTLS | | Local agent → remote-proxy | WSS | Phase 1;雛形 WS | | Local agent → Kneron USB | 無(硬體直連)| — | --- ## 4. Rate Limiting(Phase 1) | 端點 | 限制 | |------|------| | `POST /api/auth/login` | 5 / min / IP | | `POST /api/pairing/token` | 5 / min / user | | `WS /tunnel/connect` | 10 / min / IP;token 級限制另計 | | 一般 `/api/*` | 100 / min / user | **雛形不做**。記錄為 TODO。 --- ## 5. CORS ```go // internal/api/middleware/cors.go func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc { return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") if allowed(origin, allowedOrigins) { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204); return } c.Next() } } ``` **雛形**:`allowedOrigins = ["*"]`(全放) **Phase 1**:白名單 `["https://app.visiona.cloud"]` --- ## 6. WebSocket Origin Check Browser 連 `/ws/*` 時,遵循 CORS origin check(local-tool 的 `origin.go` 已成熟;搬過來)。 `/tunnel/connect` 是 local agent 連進來(非瀏覽器),**Origin check 跳過**(該 endpoint 僅 token 驗證)。 --- ## 7. CSRF - JWT-in-header(非 cookie)→ 天然免疫 CSRF - 若 Phase 1 用 session cookie,加: - `SameSite=Lax` cookie - `CSRF-Token` header(double-submit pattern) --- ## 7.1 Cookie Session 設計取捨(Phase 0.6 OIDC BFF) Phase 0.6 OIDC BFF 落地後(OB1-OB6),Cookie session 設計做出以下取捨: ### Pending 與 Logged-in Session 共用同一個 Cookie(ADR-012) `oidc-tdd.md §4.5` 原設計示意了兩個 cookie(`visiona_pending_sid` 短 TTL + `visiona_session` 長 TTL)。實作時為了減少 cookie 數量與 handler 邏輯,改採**合一**:兩種狀態共用同一個 `visiona_session` cookie + 同一個 `usersession.Store` record,由 `Session.UserID` 是否為空區分階段。 **安全防護三道線**: | # | 防線 | 位置 | 說明 | |---|------|------|------| | 1 | Middleware 強制檢查 UserID 空 → 401 | `internal/api/middleware.go` AuthMiddleware | Pending session 訪 protected endpoint 一律拒絕;該檔 169 行附「安全臨界檢查」註解明確警告不可拿掉 | | 2 | Login 完成後 rotate session ID | `internal/usersession/manager.go` RotateSessionID + `internal/api/oidc_auth.go` callback | OWASP ASVS V3.2.1 — 防止攻擊者預先誘騙受害者使用攻擊者 cookie 走完 OIDC flow | | 3 | Pending state 在 callback 同一次 UpdateSession 清空 | `internal/api/oidc_auth.go` callback | OIDCState / OIDCNonce / OIDCCodeVerifier / Extra["return_to"] 在寫 user info 同一次 commit 中清掉 | 詳見 `adr-012-pending-session-shared-cookie.md`。 ### Session ID Rotation 觸發點 | 事件 | 是否 rotate | 理由 | |------|------------|------| | 登入完成(OIDC callback) | ✅ rotate | OWASP ASVS V3.2.1,防 session fixation | | 提權(雛形無此情境) | — | Phase 1 接 RBAC 後可能需要 | | Logout | ❌(直接刪除) | 不需要 rotate,整個 session record 從 store 消除 | | 一般 API 請求 | ❌ | 維持 session ID 穩定,只刷 LastSeenAt | ### Cookie 屬性最小集(雛形 + Phase 1 共通) | 屬性 | 值 | 理由 | |------|----|----| | `HttpOnly` | true | 阻止 JS 讀取(XSS 防護) | | `Secure` | true(prod)/ false(dev HTTP) | 只在 HTTPS 傳送 | | `SameSite` | Lax | CSRF 基線防護;Strict 會擋掉 OIDC callback redirect | | `Path` | `/` | 全站可用 | | `MaxAge` | 86400(雛形 24h) | Phase 1 將擴至 7d 並接 refresh token | | HMAC-SHA256 簽章 | SigningKey ≥ 32 bytes | 防 cookie 偽造;NewManager 啟動時強制檢查 | --- ## 8. 輸入驗證 - API body 用 `binding:"required"` + `validator.v10` - 檔案上傳: - Size 限制(雛形:模型 500MB 上限) - Content-Type 白名單(`application/octet-stream`, `application/x-nef` 等) - Magic bytes 檢查(Phase 1) - Pairing token:`^vAc_[0-9a-f]{32}$` - Session token:`^vAs_[0-9a-f]{64}$` - 傳入前先 `.replace(/\s/g, '')` 正規化(允許使用者貼上含空格的顯示格式) - SQL:一律用參數化 query(`$1` / prepared statement) --- ## 9. Secret 管理 | Secret | 雛形 | Phase 1 | |--------|------|--------| | Pairing token(static)| env var | DB(hash)| | Storage signing key | env var | env var + 定期 rotation | | DB password | — | AWS Secrets Manager / Vault | | JWT signing key | — | 同上 | | S3 credentials | env var(dev)| IAM role(EC2 / K8s SA)| **禁止**:任何 secret 寫死進程式碼 / commit 到 Git。`.gitignore` 必須含 `.env`、`data/`、`*.pem`。 --- ## 10. 審計 Log(Phase 1) 關鍵事件記入 `audit_logs` 表: - 使用者登入 / 登出 / 密碼變更 - Pairing token 建立 / 撤銷 / 使用 - Device 建立 / 刪除 - Model 上傳 / 刪除 - Cluster 建立 / 推論 欄位:`id, user_id, action, resource, details_json, ip, user_agent, created_at` --- ## 11. 第三方依賴安全 - `go mod audit`(Go 1.26+)或 `govulncheck` 定期掃 - npm: `npm audit` / Dependabot - Renovate / Dependabot 自動 PR - CI 時 fail on known CVE **雛形不強制**,但建議 CI 設定。 --- ## 12. 威脅模型(STRIDE 摘要) | 威脅 | 雛形影響 | Phase 1 防護 | |------|---------|------------| | **Spoofing** — 冒用 pairing token | Dev 環境可接受 | DB-backed + 兩階段 token + rate limit | | **Tampering** — 中間人改 payload | 無 TLS,局網 OK,公網不行 | 全程 TLS | | **Repudiation** — 否認操作 | 無 audit log | audit_logs 表 | | **Info Disclosure** — token / model 洩漏 | env 獨立隔離 | TLS + DB 加密 + presigned URL 有限 TTL | | **DoS** — 灌爆 tunnel | 無防護 | rate limit + connection pool | | **Elevation of Privilege** — 讀他人 device | OIDC 已實裝(OB5 後 user_id = OIDC sub) | RBAC + repository 查詢一律帶 `WHERE owner_user_id = ?` | --- ## 14. 前端雛形安全債(Phase 1 必還) F5–F6 實作留下的**已知安全債**。雛形階段可接受(開發者自用、local 後端),但 Phase 1 對外開放前必須逐項處理。 | # | 項目 | 現況(雛形) | Phase 1 處理 | 對應程式碼 | |---|------|-------------|-------------|-----------| | 14.1 | ~~Access token 存 localStorage~~ | ~~`visionA.auth.token` key 寫入 localStorage,被同網域任何 XSS 讀走~~ **2026-04-26 已修(OF2)**:Phase 0.6 OIDC BFF 後,frontend 不再持有 token;改用 `HttpOnly; Secure; SameSite=Lax` 的 `visiona_session` cookie + server-side session(in-memory)。auth-store 改為 me-based(`GET /api/auth/me`),api client 以 `credentials: 'include'` 自動帶 cookie。 | — | `src/stores/auth-store.ts`、`src/lib/api.ts`(OF2 重寫) | | 14.2 | Refresh token 未接上 | login response 的 `refresh_token` 目前**被丟棄**,access token 過期使用者被迫重登 | 接 refresh 流程(access token 短 TTL ~15min、refresh 長 TTL,走 cookie) | `src/stores/auth-store.ts:137-173` | | 14.3 | WS token 走 querystring | `ws://.../ws/xxx?token=` — 明文進 server access log(Nginx / LB / Cloudflare),歷史 log 含 token 字串 | 改 `Sec-WebSocket-Protocol: Bearer,` subprotocol,或 HTTP 先換 ~60s ticket 再 `?ticket=...` | `src/hooks/use-websocket.ts:88-99` | | 14.4 | Device session 識別與 pairing token 型別未分離 | session-store 的 `activeSessionToken` 命名容易混淆 pairing / session token 語意 | 拆為 `activeDeviceSessionId`(不是 token 而是 id),明確區分「識別」與「密鑰」 | `src/stores/session-store.ts:47-52` | | 14.5 | CSP 尚未設定 | Next.js 預設無 CSP header | 加 strict CSP(`script-src 'self' 'nonce-xxx'`);禁止 inline eval | `next.config.ts`(尚未存在) | | 14.6 | CSRF 防護 | 無(因尚未有瀏覽器端 cookie auth) | 切到 cookie auth 時補上 double-submit token 或 SameSite=Strict | 全域 middleware | **追蹤責任**:每一項在 Phase 1 Sprint Planning 前必須有對應 ticket,不允許以「雛形 OK」為由直接上線。 --- ## 13. 合規 雛形期**不處理 GDPR / CCPA**。Phase 1 上線前必做: - [ ] Privacy Policy - [ ] Terms of Service - [ ] Cookie Policy - [ ] 資料匯出 / 刪除(GDPR right to access / erasure) - [ ] 若有歐洲用戶:DPA with processors(AWS / S3 供應商 / Email 供應商) --- **雛形實作重點**: - `StaticPairingStore` + env token - ~~`StaticAuthService` + hard-coded demo user~~ → **已替換為 OIDC**(Phase 0.6 / OB5;見 oidc-tdd.md) - CORS 限定白名單(OB5 已調整) - HTTP(dev)/ HTTPS(prod 必須) **Phase 1 必做**: - 兩階段 token - ~~真實 Auth(Clerk 或 自建)~~ → **已完成(Member Center OIDC)** - HTTPS / WSS(prod 強制) - Rate limit - Audit log - Refresh token rotation(Member Center 暫無 refresh,雛形可接受) - Redis / DB-backed session store(取代 in-memory)