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

490 lines
15 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_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"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 從自己的
// per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name
// (對 SA 而言就是 required_secrets[0].key)。有值優先用,沒有才 fallback 舊 KV+crypto_decrypt。
// 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"`
}
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]
var saJSONStr string
// T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
// input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
if v, ok := input.ResolvedSecrets[saReq.Key]; ok {
saJSONStr = v
} else {
// T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
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
}
decrypted, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + saReq.Key + " 解密失敗")
return
}
saJSONStr = decrypted
}
// 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
}