Files
magpie/internal/qoder/cosy.go
T

208 lines
6.4 KiB
Go

package qoder
// PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn")
// are deprecated built-in subscriptions served by their plugin,
// @magpie-community/opencode-qoder-auth, each once moved onto it
// (provider.Moved; the default for a new sign-in). A moved one's
// sign-ins, models, requests and usage are all the plugin's, never this
// code's (only the move, in migrate*.go, still reads its accounts). A fix here alone doesn't reach those users; fix the plugin
// (github.com/magpie-community/plugins, packages/qoder) and raise the
// movers' min in internal/provider/migrate_qoder.go.
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"net/url"
"runtime"
"strings"
"time"
)
// RSAPublicKeyPEM is Qoder's embedded 1024-bit RSA public key (from the
// client's cosy source), used to encrypt the per-request AES key that wraps
// the user blob in the COSY Authorization header.
const RSAPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
XcW+ML9FoCI6AOvOzwIDAQAB
-----END PUBLIC KEY-----`
// User is the signed-in Qoder account a COSY envelope is built for.
type User struct {
UID string // the Qoder user id (x-gw-user-id / Cosy-User value)
Name string // display name, may be empty
Email string // account email, may be empty
Token string // the jt- Bearer token (security_oauth_token)
MachineID string // generated once at sign-in, saved with the account
}
var qoderRSAKey *rsa.PublicKey
func init() {
block, _ := pem.Decode([]byte(RSAPublicKeyPEM))
if block == nil {
panic("qoder: failed to decode embedded RSA public key")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
panic("qoder: parse RSA public key: " + err.Error())
}
rsaKey, ok := pub.(*rsa.PublicKey)
if !ok {
panic("qoder: embedded key is not an RSA public key")
}
qoderRSAKey = rsaKey
}
// aes128CBCEncrypt encrypts with AES-128-CBC and PKCS#7 padding.
func aes128CBCEncrypt(key, iv, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
padLen := aes.BlockSize - len(plaintext)%aes.BlockSize
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
out := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
return out, nil
}
// rsaPKCS1Encrypt wraps an AES key with Qoder's embedded public key.
func rsaPKCS1Encrypt(key []byte) ([]byte, error) {
return rsa.EncryptPKCS1v15(rand.Reader, qoderRSAKey, key)
}
// generateUserBlob builds the AES-encrypted user info the COSY payload
// carries, returning (info, key) both base64'd. The AES key doubles as the IV.
func generateUserBlob(user *User) (string, string, error) {
blob := map[string]string{
"uid": user.UID,
"aid": "",
"name": user.Name,
"email": user.Email,
"security_oauth_token": user.Token,
}
raw, err := json.Marshal(blob)
if err != nil {
return "", "", fmt.Errorf("qoder: marshal user blob: %w", err)
}
key := []byte(strings.ReplaceAll(newUUID(), "-", "")[:16])
iv := key[:16]
infoEnc, err := aes128CBCEncrypt(key, iv, raw)
if err != nil {
return "", "", fmt.Errorf("qoder: aes encrypt user blob: %w", err)
}
keyEnc, err := rsaPKCS1Encrypt(key)
if err != nil {
return "", "", fmt.Errorf("qoder: rsa encrypt key: %w", err)
}
return base64.StdEncoding.EncodeToString(infoEnc), base64.StdEncoding.EncodeToString(keyEnc), nil
}
// urlPathname is the request's path without its /algo prefix or query, which
// is what goes into the COSY signature.
func urlPathname(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return strings.TrimSpace(rawURL)
}
path := u.Path
if strings.HasPrefix(path, "/algo") {
path = path[len("/algo"):]
}
return path
}
// BuildCosyHeaders builds the full set of COSY request headers for a Qoder
// call to rawURL with body already in its wire (encoded) form.
func BuildCosyHeaders(rawURL string, user *User, body string, timestamp int64) (map[string]string, error) {
if user == nil || user.MachineID == "" {
return nil, fmt.Errorf("qoder: missing account machine id")
}
infoB64, keyB64, err := generateUserBlob(user)
if err != nil {
return nil, err
}
requestID := strings.ReplaceAll(newUUID(), "-", "")
if timestamp == 0 {
timestamp = time.Now().Unix()
}
payloadObj := map[string]any{
"version": "v1",
"requestId": requestID,
"info": infoB64,
"cosyVersion": "1.1.49",
"ideVersion": "",
}
payloadJSON, err := json.Marshal(payloadObj)
if err != nil {
return nil, fmt.Errorf("qoder: marshal cosy payload: %w", err)
}
payload := base64.StdEncoding.EncodeToString(payloadJSON)
path := urlPathname(rawURL)
sig := cosySignature(payload, keyB64, timestamp, body, path)
auth := "Bearer COSY." + payload + "." + sig
machineID := user.MachineID
return map[string]string{
"Accept": "application/json",
"Accept-Encoding": "identity",
"Content-Type": "application/json",
"Authorization": auth,
"Cosy-Business-Product": "app",
"Cosy-Business-Type": "agent",
"Cosy-ClientIp": machineID,
"Cosy-ClientType": "10",
"Cosy-Data-Policy": "disagree",
"Cosy-Date": fmt.Sprintf("%d", timestamp),
"Cosy-Key": keyB64,
"Cosy-MachineId": machineID,
"Cosy-MachineToken": machineID,
"Cosy-MachineType": "5",
"Cosy-MachineOS": MachineOS(),
"Cosy-Scene": "app",
"Cosy-User": user.UID,
"Cosy-Version": "1.1.49",
"Login-Version": "v2",
}, nil
}
func cosySignature(payload, key string, timestamp int64, body, path string) string {
sum := md5.Sum([]byte(payload + "\n" + key + "\n" + fmt.Sprintf("%d", timestamp) + "\n" + body + "\n" + path))
return hex.EncodeToString(sum[:])
}
// MachineOS uses the architecture and platform names used by desktop clients.
func MachineOS() string {
arch := runtime.GOARCH
switch arch {
case "amd64":
arch = "x86_64"
case "386":
arch = "x86"
case "arm64":
arch = "aarch64"
}
os := runtime.GOOS
if os == "windows" {
os = "win32"
}
return arch + "_" + os
}