Files
lingma-proxy-compose/internal/remote/credentials.go
2026-05-06 12:43:55 +08:00

380 lines
10 KiB
Go

package remote
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
type Credential struct {
CosyKey string
EncryptUserInfo string
UserID string
MachineID string
Source string
TokenExpireTime int64
}
type storedCredentialFile struct {
Source string `json:"source"`
TokenExpireTime string `json:"token_expire_time"`
Auth struct {
CosyKey string `json:"cosy_key"`
EncryptUserInfo string `json:"encrypt_user_info"`
UserID string `json:"user_id"`
MachineID string `json:"machine_id"`
} `json:"auth"`
}
func LoadCredential(authFile string) (Credential, error) {
if path := strings.TrimSpace(authFile); path != "" {
return loadCredentialFile(expandHome(path))
}
return importLingmaCacheCredential()
}
func loadCredentialFile(path string) (Credential, error) {
body, err := os.ReadFile(path)
if err != nil {
return Credential{}, fmt.Errorf("read remote auth file: %w", err)
}
var stored storedCredentialFile
if err := json.Unmarshal(body, &stored); err != nil {
return Credential{}, fmt.Errorf("parse remote auth file: %w", err)
}
cred := Credential{
CosyKey: stored.Auth.CosyKey,
EncryptUserInfo: stored.Auth.EncryptUserInfo,
UserID: stored.Auth.UserID,
MachineID: stored.Auth.MachineID,
Source: valueOr(stored.Source, path),
TokenExpireTime: parseExpire(stored.TokenExpireTime),
}
return cred, validateCredential(cred)
}
func importLingmaCacheCredential() (Credential, error) {
var attempts []string
for _, lingmaDir := range candidateLingmaCacheDirs() {
cred, err := importLingmaCacheCredentialFromDir(lingmaDir)
if err == nil {
return cred, nil
}
attempts = append(attempts, fmt.Sprintf("%s: %v", lingmaDir, err))
}
if len(attempts) == 0 {
return Credential{}, errors.New("no Lingma cache directory candidate was found")
}
return Credential{}, fmt.Errorf("load Lingma login cache: %s", strings.Join(attempts, "; "))
}
func importLingmaCacheCredentialFromDir(lingmaDir string) (Credential, error) {
userPath := filepath.Join(lingmaDir, "cache", "user")
encrypted, err := os.ReadFile(userPath)
if err != nil {
return Credential{}, fmt.Errorf("read %s: %w", userPath, err)
}
machineID, err := loadMachineID(lingmaDir)
if err != nil {
return Credential{}, err
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(encrypted)))
if err != nil {
return Credential{}, fmt.Errorf("decode %s: %w", userPath, err)
}
plaintext, err := decryptCacheUser(machineID, ciphertext)
if err != nil {
return Credential{}, err
}
var payload struct {
Key string `json:"key"`
EncryptUserInfo string `json:"encrypt_user_info"`
UserID string `json:"uid"`
ExpireTime any `json:"expire_time"`
}
if err := json.Unmarshal(plaintext, &payload); err != nil {
return Credential{}, fmt.Errorf("parse %s: %w", userPath, err)
}
cred := Credential{
CosyKey: payload.Key,
EncryptUserInfo: payload.EncryptUserInfo,
UserID: payload.UserID,
MachineID: machineID,
Source: userPath,
TokenExpireTime: parseExpireAny(payload.ExpireTime),
}
return cred, validateCredential(cred)
}
func candidateLingmaCacheDirs() []string {
if explicit := strings.TrimSpace(os.Getenv("LINGMA_CACHE_DIR")); explicit != "" {
return []string{expandHome(explicit)}
}
var dirs []string
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
dirs = append(dirs,
filepath.Join(home, ".lingma"),
filepath.Join(home, ".lingma", "vscode", "sharedClientCache"),
filepath.Join(home, ".config", "Lingma"),
filepath.Join(home, ".local", "share", "Lingma"),
)
}
for _, envName := range []string{"APPDATA", "LOCALAPPDATA", "ProgramData"} {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
dirs = append(dirs,
filepath.Join(value, "Lingma"),
filepath.Join(value, "lingma"),
)
}
}
if value := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); value != "" {
dirs = append(dirs, filepath.Join(value, "Lingma"))
}
return uniquePathStrings(dirs)
}
func loadMachineID(lingmaDir string) (string, error) {
if body, err := os.ReadFile(filepath.Join(lingmaDir, "cache", "id")); err == nil {
if value := strings.TrimSpace(string(body)); value != "" {
return value, nil
}
}
for _, path := range candidateMachineIDLogFiles(lingmaDir) {
body, err := os.ReadFile(path)
if err != nil {
continue
}
if value := extractMachineIDFromText(string(body)); value != "" {
return value, nil
}
}
return "", errors.New("remote credential requires cache/id or Lingma log machine id; checked cache/id, Lingma app logs, and VS Code Lingma plugin logs")
}
func candidateMachineIDLogFiles(lingmaDir string) []string {
paths := []string{
filepath.Join(lingmaDir, "logs", "lingma.log"),
filepath.Join(lingmaDir, "logs", "Lingma.log"),
filepath.Join(lingmaDir, "logs", "main.log"),
filepath.Join(lingmaDir, "logs", "renderer.log"),
filepath.Join(lingmaDir, "logs", "sharedprocess.log"),
}
paths = append(paths, recursiveLogFiles(filepath.Join(lingmaDir, "logs"), 24)...)
if home, err := os.UserHomeDir(); err == nil {
for _, root := range lingmaLogRoots(home) {
paths = append(paths, recentLingmaAppLogs(root)...)
paths = append(paths, recursiveLogFiles(root, 24)...)
}
}
return uniquePathStrings(paths)
}
func recursiveLogFiles(root string, limit int) []string {
type item struct {
path string
modTime int64
}
items := make([]item, 0)
_ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return nil
}
name := strings.ToLower(entry.Name())
if !strings.HasSuffix(name, ".log") && !strings.Contains(name, "lingma") {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
items = append(items, item{path: path, modTime: info.ModTime().UnixNano()})
return nil
})
sort.Slice(items, func(i, j int) bool { return items[i].modTime > items[j].modTime })
if limit > 0 && len(items) > limit {
items = items[:limit]
}
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, item.path)
}
return out
}
func extractMachineIDFromText(text string) string {
markers := []string{
"using machine id from file:",
"machine id:",
"machine_id:",
"machineId:",
"machine-id:",
}
lowerText := strings.ToLower(text)
for _, marker := range markers {
index := strings.LastIndex(lowerText, strings.ToLower(marker))
if index < 0 {
continue
}
line := text[index+len(marker):]
if newline := strings.IndexByte(line, '\n'); newline >= 0 {
line = line[:newline]
}
if value := normalizeMachineID(line); value != "" {
return value
}
}
re := regexp.MustCompile(`(?i)"?(machine[_-]?id|machineId)"?\s*[:=]\s*"?([A-Za-z0-9._:-]{16,})"?`)
matches := re.FindAllStringSubmatch(text, -1)
for i := len(matches) - 1; i >= 0; i-- {
if len(matches[i]) >= 3 {
if value := normalizeMachineID(matches[i][2]); value != "" {
return value
}
}
}
return ""
}
func normalizeMachineID(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, ` "'<>),]}`)
if idx := strings.IndexAny(value, " \t\r\n,;"); idx >= 0 {
value = value[:idx]
}
value = strings.Trim(value, ` "'<>),]}`)
if len(value) < aes.BlockSize {
return ""
}
return value
}
func decryptCacheUser(machineID string, ciphertext []byte) ([]byte, error) {
if len(machineID) < aes.BlockSize {
return nil, errors.New("machine id too short for cache decryption")
}
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New("invalid cache/user ciphertext size")
}
key := []byte(machineID[:aes.BlockSize])
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
plaintext := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, key).CryptBlocks(plaintext, ciphertext)
return unpadPKCS7(plaintext)
}
func unpadPKCS7(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty plaintext")
}
padLen := int(data[len(data)-1])
if padLen <= 0 || padLen > aes.BlockSize || padLen > len(data) {
return nil, errors.New("invalid cache/user padding")
}
for _, b := range data[len(data)-padLen:] {
if int(b) != padLen {
return nil, errors.New("invalid cache/user padding bytes")
}
}
return data[:len(data)-padLen], nil
}
func validateCredential(cred Credential) error {
if strings.TrimSpace(cred.CosyKey) == "" {
return errors.New("remote credential missing cosy_key")
}
if strings.TrimSpace(cred.EncryptUserInfo) == "" {
return errors.New("remote credential missing encrypt_user_info")
}
if strings.TrimSpace(cred.UserID) == "" {
return errors.New("remote credential missing user_id")
}
if strings.TrimSpace(cred.MachineID) == "" {
return errors.New("remote credential missing machine_id")
}
return nil
}
func parseExpire(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
func parseExpireAny(value any) int64 {
switch typed := value.(type) {
case string:
return parseExpire(typed)
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
default:
return 0
}
}
func IsExpired(cred Credential, margin time.Duration) bool {
return cred.TokenExpireTime > 0 && time.Now().Add(margin).UnixMilli() > cred.TokenExpireTime
}
func MachineOSHeader() string {
switch runtime.GOOS {
case "darwin":
if runtime.GOARCH == "arm64" {
return "arm64_darwin"
}
return "x86_64_darwin"
case "windows":
if runtime.GOARCH == "arm64" {
return "arm64_windows"
}
return "x86_64_windows"
case "linux":
if runtime.GOARCH == "arm64" {
return "arm64_linux"
}
return "x86_64_linux"
default:
return runtime.GOARCH + "_" + runtime.GOOS
}
}
func uniquePathStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
cleaned := filepath.Clean(value)
key := strings.ToLower(cleaned)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, cleaned)
}
return out
}