da84425d25
壓測 401 根因:{{credential.X}} 系統沒實裝,三條 template 展開路徑都不認
credential. namespace → 注入空值 → 目標 API 401(test_arcrun/5 Haiku 實證)。
修法(design §8,richblack 確認方向 B「讓 {{credential.X}} 真的能用」):
- auth_static_key 加 resolve_credentials action:給 names → WASM 內 kv_get +
crypto_decrypt → 回明文 map(不查 recipe、缺則誠實報錯)
- auth-dispatcher 加 resolveCredentialRefs:遞迴偵測 {{credential.X}} → 交 WASM
解密 → 回填(無 ref 則零開銷不打 WASM)
- graph-executor 在 node.data interpolate 後呼叫,不碰 ENCRYPTION_KEY(rule 02 §2.2)
解密全程在 WASM,TS 只偵測+回填。tinygo build OK + tsc 0 + §2.2 自檢綠。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
359 lines
11 KiB
Go
359 lines
11 KiB
Go
// 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"`
|
||
// Names:resolve_credentials action 用——要解密的 credential 名稱清單
|
||
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
|
||
Names []string `json:"names,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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|