Files
uncle6me-web ed2e42e007 feat(credentials): T6 讀取/注入接新家 + T7 雙讀 fallback (credential-store-migration 方案 A)
D19「擁有目錄不擁有內容物」遷移的讀取斷點。密文住 cypher per-script secrets、
D1 只存 secret_ref、auth worker 讀不到 cypher 的 secrets → cypher 先取值塞 payload。

WASM 端(T7 fallback 骨架,零行為改變可獨立部署驗):
- auth_static_key/main.go + auth_service_account/main.go:Input 加 resolved_secrets,
  解密處改「有 resolved 就用、沒有才 fallback 舊 kv_get+crypto_decrypt」。default 等於舊碼。
- tinygo build 兩支通過,copy 到 .component-builds/*/component.wasm。

TS 端(T6 主路徑):
- auth-dispatcher.ts 新增 resolveSecretsFromNewHome:查 D1 拿 secret_ref →
  secret_get(env[ref], T4) 取明文 → 組 map(取不到的 name 缺席,不放空字串) →
  更新 last_used_at。tryAuthDispatch + resolveCredentialRefs 都塞 resolved_secrets。
- rule 02 §2.2 對齊:只查 ref/取值/塞字串,不解密不展開模板不組 JWT。

驗證:cypher-executor + cli tsc exit 0;vitest 41/42(新增 auth-dispatcher.test.ts 6 案例全過,
剩 1 pre-existing 無關失敗)。待 leo21c 部署驗 WASM 端到端(不由本任務部署,acr update
硬綁 GitHub codeload=mistakes #23)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:44:26 +08:00

381 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// auth_static_key — static key auth primitive
//
// 讀取 auth_recipe:{service} + 解密 required_secrets + 展開 {{secret.X}} 模板,
// 回傳 auth_headers / auth_query / auth_body。
//
// 所有外部 I/O 都透過 host function:
// - u6u.kv_get — 依 key 前綴路由到 RECIPES / CREDENTIALS_KV (host 做越權檢查)
// - u6u.crypto_decrypt — AES-GCM 解密 (encryption key 由 host 持有,不暴露給 WASM)
//
//go:build tinygo
package main
import (
"encoding/base64"
"encoding/json"
"io"
"os"
"strings"
"unsafe"
)
// ── host function 宣告 ───────────────────────────────────────────────────────
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功 / 1 錯誤 / 2 找不到
//
//go:wasmimport u6u kv_get
func hostKvGet(
keyPtr uintptr, keyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// enc/iv 為 base64 字串(即 KV 中儲存的格式)
//
//go:wasmimport u6u crypto_decrypt
func hostCryptoDecrypt(
encPtr uintptr, encLen uint32,
ivPtr uintptr, ivLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
// ── 型別 ─────────────────────────────────────────────────────────────────────
type Input struct {
Action string `json:"action"`
APIKey string `json:"api_key"`
Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"`
// Namesresolve_credentials action 用——要解密的 credential 名稱清單
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
Names []string `json:"names,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 已從
// 自己的 per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name。
// 有值就優先用,沒有才 fallback 舊路徑(kv_get {api_key}:cred:{name} + crypto_decrypt),
// 這個 fallback 就是雙讀(T7)。default(此欄空/nil)行為完全等於遷移前的舊碼。
ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"`
}
type SecretRequirement struct {
Key string `json:"key"`
Label string `json:"label"`
Optional bool `json:"optional,omitempty"`
}
type AuthInjectSpec struct {
Header map[string]string `json:"header,omitempty"`
Query map[string]string `json:"query,omitempty"`
Body map[string]string `json:"body,omitempty"`
// Path:要注入 endpoint URL path 的 secret(如 telegram /bot{token}/)。
// key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。
Path map[string]string `json:"path,omitempty"`
}
type AuthRecipe struct {
Kind string `json:"kind"`
Service string `json:"service"`
Primitive string `json:"primitive"`
RequiredSecrets []SecretRequirement `json:"required_secrets"`
Inject AuthInjectSpec `json:"inject"`
}
type EncryptedRecord struct {
Encrypted string `json:"encrypted"`
IV string `json:"iv"`
}
// ── main ─────────────────────────────────────────────────────────────────────
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
if input.APIKey == "" {
writeError("api_key 必填")
return
}
// resolve_credentials:用戶面 {{credential.NAME}} 入口。不查 recipe、不要求 service
// 直接給 names 解密回明文。在 service 必填檢查之前分流(只有 authenticate 才需要 recipe)。
if input.Action == "resolve_credentials" {
handleResolveCredentials(input)
return
}
if input.Service == "" {
writeError("service 必填")
return
}
if input.Action != "" && input.Action != "authenticate" {
writeError("auth_static_key 僅支援 action=authenticate / resolve_credentials")
return
}
// 1. 讀 auth recipe
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
if status == 2 {
writeError("找不到 auth recipe: " + input.Service)
return
}
if status != 0 {
writeError("kv_get 失敗(auth_recipe)")
return
}
var recipe AuthRecipe
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
writeError("auth recipe JSON 解析失敗: " + err.Error())
return
}
if recipe.Primitive != "static_key" {
writeError("auth recipe " + input.Service + " 的 primitive 不是 static_key (是 " + recipe.Primitive + ")")
return
}
// 2. 解密所有 non-optional required_secrets
secrets := make(map[string]string)
for _, req := range recipe.RequiredSecrets {
if req.Optional {
continue
}
// T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
// input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
if v, ok := input.ResolvedSecrets[req.Key]; ok {
secrets[req.Key] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 走舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + req.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + req.Key + " (" + req.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + req.Key + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + req.Key + " 格式錯誤: " + err.Error())
return
}
plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + req.Key + " 解密失敗")
return
}
secrets[req.Key] = plaintext
}
// 3. 展開模板 (static_key 沒有 runtime,傳空 map)
runtime := map[string]string{}
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
authPath := interpolateRecord(recipe.Inject.Path, secrets, runtime)
// 3.5 Basic Auth 自動編碼:若 header 值為 "Basic <x>:<y>" (冒號代表未編碼的 user:pass),
// 將冒號分隔部分做 base64。這涵蓋 twilio / jira / mailgun 等 Basic Auth recipe。
// "Basic <already-base64>" (無冒號) 維持原樣,向後相容。
// header key 不分大小寫比對 "authorization"。
for k, v := range authHeaders {
if !strings.EqualFold(k, "Authorization") {
continue
}
const prefix = "Basic "
if !strings.HasPrefix(v, prefix) {
continue
}
payload := v[len(prefix):]
if !strings.Contains(payload, ":") {
continue
}
authHeaders[k] = prefix + base64.StdEncoding.EncodeToString([]byte(payload))
}
// 4. 輸出
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"auth_headers": authHeaders,
"auth_query": authQuery,
"auth_body": authBody,
"auth_path": authPath,
"runtime": runtime,
})
os.Stdout.Write(out)
}
// handleResolveCredentials 處理用戶面 {{credential.NAME}} 入口:
// 對每個 name 讀 {api_key}:cred:{name} + 解密,回傳明文 map。
// 不查 auth recipe(與 authenticate 分流)。缺任一 name → success:false + error 指明(不假綠)。
func handleResolveCredentials(input Input) {
if len(input.Names) == 0 {
writeError("resolve_credentials 需要 names(要解密的 credential 名稱清單)")
return
}
credentials := make(map[string]string, len(input.Names))
for _, name := range input.Names {
if name == "" {
continue
}
// T6 方案 A:cypher 已從新家取到明文就直接用(同 authenticate 分支)。
if v, ok := input.ResolvedSecrets[name]; ok {
credentials[name] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + name
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + name + "。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + name + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + name + " 格式錯誤: " + err.Error())
return
}
plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + name + " 解密失敗")
return
}
credentials[name] = plaintext
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"credentials": credentials,
})
os.Stdout.Write(out)
}
// ── helpers ──────────────────────────────────────────────────────────────────
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": msg,
"auth_headers": map[string]string{},
"auth_query": map[string]string{},
"auth_body": map[string]string{},
})
os.Stdout.Write(out)
}
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
var outLen uint32
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", status
}
return string(outBuf[:outLen]), 0
}
// cryptoDecrypt 呼叫 host function 做 AES-GCM 解密
// enc/iv 均為 base64 字串;回傳 UTF-8 plaintext
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
var outLen uint32
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
if len(encBytes) == 0 || len(ivBytes) == 0 {
return "", false
}
status := hostCryptoDecrypt(
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
}
return string(outBuf[:outLen]), true
}
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串(與 TS 版 parity)。
// 其他 namespace 的 {{...}} 原樣保留(static_key 不解析)。
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
var b strings.Builder
b.Grow(len(template))
i := 0
for i < len(template) {
start := strings.Index(template[i:], "{{")
if start < 0 {
b.WriteString(template[i:])
break
}
b.WriteString(template[i : i+start])
openIdx := i + start
closeRel := strings.Index(template[openIdx+2:], "}}")
if closeRel < 0 {
b.WriteString(template[openIdx:])
break
}
inner := template[openIdx+2 : openIdx+2+closeRel]
advance := openIdx + 2 + closeRel + 2
switch {
case strings.HasPrefix(inner, "secret."):
key := inner[len("secret."):]
b.WriteString(secrets[key])
case strings.HasPrefix(inner, "runtime."):
key := inner[len("runtime."):]
b.WriteString(runtime[key])
default:
// 非本 primitive 負責的 namespace,原樣寫回
b.WriteString(template[openIdx:advance])
}
i = advance
}
return b.String()
}
func interpolateRecord(
record map[string]string,
secrets, runtime map[string]string,
) map[string]string {
if record == nil {
return map[string]string{}
}
result := make(map[string]string, len(record))
for k, v := range record {
result[k] = interpolateTemplate(v, secrets, runtime)
}
return result
}