Update proxy to Zhanlu v1.4.2 provider flow
The 1.4.2 extension replaced the old signed/encrypted chat gateway with an
OpenAI-compatible aigateway. Align the proxy with the new flow:
- Use ecloud.10086.cn login/model base URLs, zhanlu_ide plugin headers and
v1.4.2 plugin version
- Provision the model API key via SM2-signed get-or-create after v1/login
profile fetch; store api_key/model_base_url/email in credentials
- Chat via Bearer apiKey against {modelBaseUrl}/chat/completions with plain
OpenAI SSE passthrough; fetch /v1/models from the gateway model-info endpoint
- Force HTTP/1.1 upstream (gateway drops HTTP/2 ALPN negotiation with EOF)
- Drop obsolete AES body encryption, model name mapping and vscode headers
This commit is contained in:
+8
-19
@@ -33,17 +33,16 @@ func DecryptCredential(ciphertextBase64, key string) (string, error) {
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func EncryptCredential(plaintext, key string) (string, error) {
|
||||
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
// DecryptCredentialOrRaw mirrors the plugin's z4A: try AES-ECB decrypt with the
|
||||
// given key, falling back to the raw value when the field is plaintext.
|
||||
func DecryptCredentialOrRaw(value, key string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
|
||||
out := make([]byte, len(plain))
|
||||
for start := 0; start < len(plain); start += aes.BlockSize {
|
||||
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
|
||||
if plain, err := DecryptCredential(value, key); err == nil && plain != "" {
|
||||
return plain
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
return value
|
||||
}
|
||||
|
||||
func repeatKey(key string, size int) []byte {
|
||||
@@ -73,13 +72,3 @@ func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
|
||||
}
|
||||
return in[:len(in)-pad], nil
|
||||
}
|
||||
|
||||
func padPKCS7(in []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(in)%blockSize
|
||||
out := make([]byte, len(in)+pad)
|
||||
copy(out, in)
|
||||
for i := len(in); i < len(out); i++ {
|
||||
out[i] = byte(pad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -10,14 +10,30 @@ import (
|
||||
)
|
||||
|
||||
type Credentials struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Token string `json:"token"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Token string `json:"token"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
ModelBaseURL string `json:"model_base_url,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Email string
|
||||
Organization string
|
||||
Team string
|
||||
UserName string
|
||||
Telephone string
|
||||
}
|
||||
|
||||
func (c Credentials) Validate() error {
|
||||
if strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(c.AccessKey) == "" {
|
||||
return errors.New("access_key is required")
|
||||
}
|
||||
@@ -30,6 +46,10 @@ func (c Credentials) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Credentials) HasAPIKey() bool {
|
||||
return strings.TrimSpace(c.APIKey) != "" && strings.TrimSpace(c.ModelBaseURL) != ""
|
||||
}
|
||||
|
||||
func LoadCredentials(path string) (Credentials, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/emmansun/gmsm/sm2"
|
||||
"github.com/emmansun/gmsm/sm3"
|
||||
)
|
||||
|
||||
// SignSM2Authorization signs `message` with the SM2 private key in hex form
|
||||
// (mirroring the Zhanlu plugin: SM3 digest signed with hash:false, der:false,
|
||||
// output as 64-byte r||s hex).
|
||||
func SignSM2Authorization(privateKeyHex, message string) (string, error) {
|
||||
keyHex := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
|
||||
keyBytes, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, err := sm2.NewPrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sm3.Sum([]byte(message))
|
||||
r, s, err := sm2.Sign(rand.Reader, &priv.PrivateKey, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rb := r.FillBytes(make([]byte, 32))
|
||||
sb := s.FillBytes(make([]byte, 32))
|
||||
return hex.EncodeToString(append(rb, sb...)), nil
|
||||
}
|
||||
|
||||
var errEmptySM2Key = errors.New("SM2 private key is required")
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignSM2Authorization(t *testing.T) {
|
||||
const privHex = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
|
||||
sig, err := SignSM2Authorization(privHex, "1754460000:AbCdEfGh1234567890AbCdEfGh123456:{\"email\":\"[email protected]\"}")
|
||||
if err != nil {
|
||||
t.Fatalf("SignSM2Authorization: %v", err)
|
||||
}
|
||||
if len(sig) != 128 {
|
||||
t.Fatalf("signature length = %d, want 128 (r||s hex)", len(sig))
|
||||
}
|
||||
if _, err := hex.DecodeString(sig); err != nil {
|
||||
t.Fatalf("signature is not hex: %v", err)
|
||||
}
|
||||
// Deterministic inputs must produce a stable signature across calls only if
|
||||
// the nonce is fixed; sm-crypto randomizes k, so just check shape + parse.
|
||||
if strings.TrimSpace(sig) != sig {
|
||||
t.Fatalf("signature contains whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignSM2AuthorizationInvalidKey(t *testing.T) {
|
||||
if _, err := SignSM2Authorization("zz", "x"); err == nil {
|
||||
t.Fatal("expected error for invalid private key hex")
|
||||
}
|
||||
}
|
||||
+54
-38
@@ -10,74 +10,90 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExchangeResponse struct {
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
Message string `json:"message"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
|
||||
// ExchangeCode exchanges an SSO auth code for a user profile via the Zhanlu
|
||||
// gateway authToken endpoint (POST /api/acepilot/zhanlu/authToken). The
|
||||
// profile fields may be AES-ECB encrypted with the token decrypt key; each
|
||||
// field falls back to the raw value when decryption fails.
|
||||
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Profile, error) {
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
return Credentials{}, errors.New("exchange endpoint is required")
|
||||
return Profile{}, errors.New("exchange endpoint is required")
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return Credentials{}, errors.New("code is required")
|
||||
}
|
||||
if strings.TrimSpace(decryptKey) == "" {
|
||||
return Credentials{}, errors.New("decrypt key is required")
|
||||
return Profile{}, errors.New("code is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 60 * time.Second}
|
||||
// HTTP/1.1 only: the Zhanlu gateway drops HTTP/2 negotiation.
|
||||
client = &http.Client{Timeout: 60 * time.Second, Transport: &http.Transport{ForceAttemptHTTP2: false}}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"code": code})
|
||||
body, _ := json.Marshal(map[string]string{"deputyAccountNumber": code})
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
return Profile{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
return Profile{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b := make([]byte, 1024)
|
||||
n, _ := resp.Body.Read(b)
|
||||
return Profile{}, fmt.Errorf("exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b[:n])))
|
||||
}
|
||||
|
||||
var exchange ExchangeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
|
||||
return Credentials{}, err
|
||||
return Profile{}, err
|
||||
}
|
||||
if exchange.ErrorCode != "Success" {
|
||||
if exchange.ErrorCode != "" && exchange.ErrorCode != "Success" {
|
||||
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
|
||||
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
|
||||
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
||||
}
|
||||
if exchange.State != "" && exchange.State != "OK" {
|
||||
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.State)
|
||||
return Profile{}, fmt.Errorf("exchange failed: %s", msg)
|
||||
}
|
||||
|
||||
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
profile := Profile{}
|
||||
for _, m := range []map[string]any{exchange.Body, exchange.Result, exchange.Data} {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
profile.Email = decryptProfileField(m, "email", decryptKey)
|
||||
profile.Organization = decryptProfileField(m, "organization", decryptKey)
|
||||
profile.Team = decryptProfileField(m, "team", decryptKey)
|
||||
profile.UserName = decryptProfileField(m, "name", decryptKey)
|
||||
profile.Telephone = decryptProfileField(m, "telephone", decryptKey)
|
||||
if profile.Email != "" || profile.Organization != "" || profile.Team != "" {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
|
||||
return Profile{}, errors.New("exchange response body missing profile fields")
|
||||
}
|
||||
|
||||
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
|
||||
v, ok := body[key]
|
||||
func decryptProfileField(m map[string]any, key, decryptKey string) string {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("response body missing %s", key)
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("response body %s is not a string", key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return DecryptCredential(strings.TrimSpace(s), decryptKey)
|
||||
return DecryptCredentialOrRaw(strings.TrimSpace(s), decryptKey)
|
||||
}
|
||||
|
||||
type ExchangeResponse struct {
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
Message string `json:"message"`
|
||||
State string `json:"state"`
|
||||
Body map[string]any `json:"body"`
|
||||
Result map[string]any `json:"result"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExchangeCodeAuthTokenFlow(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/acepilot/zhanlu/authToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
var in map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in["deputyAccountNumber"] != "dep-123" {
|
||||
t.Errorf("deputyAccountNumber = %q", in["deputyAccountNumber"])
|
||||
}
|
||||
// plaintext profile (DecryptCredentialOrRaw fallback)
|
||||
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{
|
||||
"email": "[email protected]", "organization": "org", "team": "team",
|
||||
}})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
profile, err := ExchangeCode(&http.Client{}, ts.URL+"/api/acepilot/zhanlu/authToken", "dep-123", "3jw7woww2rvhla6k")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode: %v", err)
|
||||
}
|
||||
if profile.Email != "[email protected]" || profile.Organization != "org" || profile.Team != "team" {
|
||||
t.Fatalf("profile = %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeMissingFields(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeTestJSON(w, map[string]any{"state": "OK", "body": map[string]any{}})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
if _, err := ExchangeCode(&http.Client{}, ts.URL+"/x", "dep", ""); err == nil {
|
||||
t.Fatal("expected error for empty profile")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user