Files
Arcrun/registry/components/auth_static_key/main.go
T
uncle6me-web da84425d25 feat(credential-injection): {{credential.X}} 用戶面語法(credential-primitives §8)
壓測 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>
2026-06-10 13:55:31 +08:00

359 lines
11 KiB
Go
Raw 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"`
}
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
}