arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
// auth_service_account — Google Service Account JWT auth primitive
|
||||
//
|
||||
// 讀取 auth_recipe:{service} + 解密 service_account_json + 組 JWT + RS256 簽章(透過 host)
|
||||
// + token exchange → access_token + 展開 {{runtime.access_token}}。
|
||||
//
|
||||
// Host imports:
|
||||
// - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV
|
||||
// - u6u.crypto_decrypt — AES-GCM 解密 service account JSON
|
||||
// - u6u.crypto_sign_rs256 — RSASSA-PKCS1-v1_5 + SHA-256 (PKCS8 private key)
|
||||
// - u6u.http_request — POST token exchange endpoint
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
func hostKvGet(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_decrypt
|
||||
func hostCryptoDecrypt(
|
||||
encPtr uintptr, encLen uint32,
|
||||
ivPtr uintptr, ivLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_sign_rs256
|
||||
func hostCryptoSignRS256(
|
||||
dataPtr uintptr, dataLen uint32,
|
||||
pkcs8Ptr uintptr, pkcs8Len uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen 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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type TokenExchange struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
Kind string `json:"kind"`
|
||||
Service string `json:"service"`
|
||||
Primitive string `json:"primitive"`
|
||||
ServiceAccountKind string `json:"service_account_kind,omitempty"`
|
||||
TokenExchange *TokenExchange `json:"token_exchange,omitempty"`
|
||||
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||
Inject AuthInjectSpec `json:"inject"`
|
||||
}
|
||||
|
||||
type EncryptedRecord struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
type ServiceAccountJSON struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type JWTHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
type JWTPayload struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud string `json:"aud"`
|
||||
Scope string `json:"scope"`
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
if input.Action != "" && input.Action != "authenticate" {
|
||||
writeError("auth_service_account 僅支援 action=authenticate")
|
||||
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 != "service_account" {
|
||||
writeError("auth recipe " + input.Service + " 的 primitive 不是 service_account (是 " + recipe.Primitive + ")")
|
||||
return
|
||||
}
|
||||
if recipe.ServiceAccountKind != "google_jwt" {
|
||||
writeError("auth recipe " + input.Service + " 的 service_account_kind 必須是 google_jwt,實際: " + recipe.ServiceAccountKind)
|
||||
return
|
||||
}
|
||||
if recipe.TokenExchange == nil || recipe.TokenExchange.Endpoint == "" {
|
||||
writeError("auth recipe " + input.Service + " 缺少 token_exchange.endpoint")
|
||||
return
|
||||
}
|
||||
if len(recipe.RequiredSecrets) == 0 {
|
||||
writeError("auth recipe " + input.Service + " 缺少 required_secrets[0](SA JSON)")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON)
|
||||
saReq := recipe.RequiredSecrets[0]
|
||||
kvKey := input.APIKey + ":cred:" + saReq.Key
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
|
||||
return
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + saReq.Key + ")")
|
||||
return
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + saReq.Key + " 解密失敗")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 解析 service account JSON
|
||||
var sa ServiceAccountJSON
|
||||
if err := json.Unmarshal([]byte(saJSONStr), &sa); err != nil {
|
||||
writeError("service account JSON 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
if sa.ClientEmail == "" || sa.PrivateKey == "" {
|
||||
writeError("service account JSON 缺少 client_email 或 private_key")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. PEM → PKCS8 bytes (去 header/footer + base64 decode)
|
||||
pkcs8, err := pemToPkcs8(sa.PrivateKey)
|
||||
if err != nil {
|
||||
writeError("解析 service account private key 失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 組 JWT header + payload (base64url-encoded)
|
||||
now := time.Now().Unix()
|
||||
header := JWTHeader{Alg: "RS256", Typ: "JWT"}
|
||||
payload := JWTPayload{
|
||||
Iss: sa.ClientEmail,
|
||||
Sub: sa.ClientEmail,
|
||||
Aud: recipe.TokenExchange.Endpoint,
|
||||
Scope: strings.Join(recipe.TokenExchange.Scopes, " "),
|
||||
Iat: now,
|
||||
Exp: now + 3600,
|
||||
}
|
||||
headerBytes, _ := json.Marshal(header)
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
|
||||
// 6. 呼叫 host 簽章 (RSASSA-PKCS1-v1_5 + SHA-256)
|
||||
signature, ok := cryptoSignRS256([]byte(signingInput), pkcs8)
|
||||
if !ok {
|
||||
writeError("JWT 簽章失敗(host function crypto_sign_rs256 回傳錯誤)")
|
||||
return
|
||||
}
|
||||
|
||||
jwt := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
|
||||
// 7. token exchange:POST form-urlencoded 到 token_exchange.endpoint
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", jwt)
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
|
||||
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if !ok {
|
||||
writeError("token exchange HTTP 失敗")
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil {
|
||||
writeError("token exchange 回應解析失敗: " + err.Error() + " (raw: " + respStr + ")")
|
||||
return
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
errMsg := tokenResp.Error
|
||||
if tokenResp.ErrorDesc != "" {
|
||||
errMsg += ": " + tokenResp.ErrorDesc
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "access_token 為空 (raw: " + respStr + ")"
|
||||
}
|
||||
writeError("token exchange 失敗: " + errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 8. 展開模板 (service_account 不用 secret.*,只用 runtime.access_token)
|
||||
secrets := map[string]string{}
|
||||
runtime := map[string]string{"access_token": tokenResp.AccessToken}
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"runtime": runtime,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
// pemToPkcs8 從 PEM 取出 base64 body 再 decode 成 bytes。
|
||||
// 支援 "BEGIN PRIVATE KEY" / "BEGIN RSA PRIVATE KEY"(SA JSON 幾乎都是前者)。
|
||||
func pemToPkcs8(pem string) ([]byte, error) {
|
||||
// 移除所有 BEGIN/END 行與空白
|
||||
lines := strings.Split(pem, "\n")
|
||||
var b strings.Builder
|
||||
for _, line := range lines {
|
||||
l := strings.TrimSpace(line)
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "-----BEGIN") || strings.HasPrefix(l, "-----END") {
|
||||
continue
|
||||
}
|
||||
b.WriteString(l)
|
||||
}
|
||||
cleaned := strings.ReplaceAll(b.String(), "\\n", "") // 防呆:JSON-escaped newline
|
||||
return base64.StdEncoding.DecodeString(cleaned)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// cryptoSignRS256 呼叫 host,回傳簽章 bytes
|
||||
func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
if len(data) == 0 || len(pkcs8) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoSignRS256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
uintptr(unsafe.Pointer(&pkcs8[0])), uint32(len(pkcs8)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, false
|
||||
}
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
|
||||
func httpRequest(url, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
// bodyBytes 可能為空(GET),host function 允許 len=0
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
var headersPtr uintptr
|
||||
if len(headersBytes) > 0 {
|
||||
headersPtr = uintptr(unsafe.Pointer(&headersBytes[0]))
|
||||
}
|
||||
|
||||
status := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
headersPtr, uint32(len(headersBytes)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
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 展開為空字串。
|
||||
// 其他 namespace 的 {{...}} 原樣保留。
|
||||
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:
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user