Files

297 lines
8.1 KiB
Go

package main
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const clientVersion = "2.0.1"
type appConfig struct {
DisplayName string
AllowedAPIs []string
}
var appRegistry = map[string]appConfig{
"order_site": {
DisplayName: "订单后台",
AllowedAPIs: []string{
"https://ks.khhao.com",
"https://order.khhao.com",
"http://localhost",
"http://127.0.0.1",
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.10.6",
"http://192.168.10.6:5173",
},
},
"live_hub": {
DisplayName: "直播运营后台",
AllowedAPIs: []string{
"https://live.khhao.com",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:8800",
"http://127.0.0.1:8800",
},
},
"affiliate_dash": {
DisplayName: "皮肤分销后台",
AllowedAPIs: []string{
"https://skin.khhao.com",
"http://localhost:15173",
"http://127.0.0.1:15173",
},
},
}
type identity struct {
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
deviceID string
}
type authorizeRequest struct {
App string `json:"app"`
ChallengeID string `json:"challengeId"`
Challenge string `json:"challenge"`
DeviceID string `json:"deviceId"`
PublicKey string `json:"publicKey"`
Signature string `json:"signature"`
Timestamp int64 `json:"timestamp"`
ClientVersion string `json:"clientVersion"`
}
func main() {
runClient(findProtocolArg(os.Args[1:]))
}
type authorizeTarget struct {
App string
ChallengeID string
Challenge string
APIBase string
}
func findProtocolArg(args []string) string {
for _, arg := range args {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(arg)), "dcauth://") {
return strings.TrimSpace(arg)
}
}
return ""
}
func parseAuthorizeURL(raw string) (authorizeTarget, error) {
parsed, err := url.Parse(raw)
if err != nil {
return authorizeTarget{}, fmt.Errorf("无法解析协议地址")
}
if !strings.EqualFold(parsed.Scheme, "dcauth") {
return authorizeTarget{}, fmt.Errorf("协议 scheme 不是 dcauth")
}
if !strings.EqualFold(parsed.Host, "authorize") && !strings.EqualFold(strings.Trim(parsed.Path, "/"), "authorize") {
return authorizeTarget{}, fmt.Errorf("不支持的协议动作")
}
query := parsed.Query()
appID := strings.TrimSpace(query.Get("app"))
challengeID := strings.TrimSpace(query.Get("challenge_id"))
challenge := strings.TrimSpace(query.Get("challenge"))
apiBase := strings.TrimRight(strings.TrimSpace(query.Get("api")), "/")
if appID == "" || challengeID == "" || challenge == "" || apiBase == "" {
return authorizeTarget{}, fmt.Errorf("缺少 app、challenge_id、challenge 或 api 参数")
}
if len(challengeID) > 128 || len(challenge) > 512 {
return authorizeTarget{}, fmt.Errorf("challenge 参数过长")
}
config, ok := appRegistry[appID]
if !ok {
return authorizeTarget{}, fmt.Errorf("未知应用:%s", appID)
}
origin, err := normalizeOrigin(apiBase)
if err != nil {
return authorizeTarget{}, err
}
if !containsOrigin(config.AllowedAPIs, origin) {
return authorizeTarget{}, fmt.Errorf("应用 %s 不允许访问 %s", appID, origin)
}
return authorizeTarget{
App: appID,
ChallengeID: challengeID,
Challenge: challenge,
APIBase: origin,
}, nil
}
func normalizeOrigin(raw string) (string, error) {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("api 地址无效")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return "", fmt.Errorf("api 地址必须使用 http 或 https")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("api 地址不允许携带用户信息、查询串或片段")
}
path := strings.TrimRight(parsed.EscapedPath(), "/")
if path != "" {
return "", fmt.Errorf("api 必须是不带路径的 origin")
}
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil
}
func containsOrigin(allowed []string, origin string) bool {
for _, item := range allowed {
if strings.EqualFold(strings.TrimRight(item, "/"), origin) {
return true
}
}
return false
}
func submitAuthorization(id identity, target authorizeTarget) error {
timestamp := time.Now().Unix()
canonical := buildAuthorizationCanonical(id, target, timestamp)
signature := ed25519.Sign(id.privateKey, []byte(canonical))
payload := authorizeRequest{
App: target.App,
ChallengeID: target.ChallengeID,
Challenge: target.Challenge,
DeviceID: id.deviceID,
PublicKey: base64.RawURLEncoding.EncodeToString(id.publicKey),
Signature: base64.RawURLEncoding.EncodeToString(signature),
Timestamp: timestamp,
ClientVersion: clientVersion,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("生成请求失败")
}
request, err := http.NewRequest(http.MethodPost, target.APIBase+"/api/dcauth/authorize", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("生成请求失败")
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "DCAuth/"+clientVersion)
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("无法连接服务器:%w", err)
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("服务器拒绝本机验证(HTTP %d):%s", response.StatusCode, summarizeResponse(responseBody))
}
return nil
}
func buildAuthorizationCanonical(id identity, target authorizeTarget, timestamp int64) string {
return strings.Join([]string{
"dcauth.v2",
target.App,
target.ChallengeID,
target.Challenge,
id.deviceID,
fmt.Sprintf("%d", timestamp),
}, "\n")
}
func summarizeResponse(body []byte) string {
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
for _, key := range []string{"msg", "message", "detail", "error"} {
if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
}
text := strings.TrimSpace(string(body))
if len(text) > 200 {
text = text[:200] + "..."
}
if text == "" {
return "无响应内容"
}
return text
}
func deviceKeyPath() (string, error) {
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if base == "" {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("无法定位用户配置目录")
}
base = configDir
}
dir := filepath.Join(base, "DCAuth")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("无法创建设备密钥目录:%w", err)
}
return filepath.Join(dir, "device.key"), nil
}
func loadOrCreateIdentity() (identity, error) {
keyPath, err := deviceKeyPath()
if err != nil {
return identity{}, err
}
var privateKey ed25519.PrivateKey
encrypted, readErr := os.ReadFile(keyPath)
if readErr == nil {
raw, err := unprotectData(encrypted)
if err != nil {
return identity{}, fmt.Errorf("设备密钥无法解密,请确认当前 Windows 用户未变化")
}
if len(raw) != ed25519.PrivateKeySize {
return identity{}, fmt.Errorf("设备密钥文件损坏,请删除 %s 后重试", keyPath)
}
privateKey = ed25519.PrivateKey(raw)
} else if os.IsNotExist(readErr) {
_, generated, err := ed25519.GenerateKey(nil)
if err != nil {
return identity{}, fmt.Errorf("生成设备密钥失败")
}
privateKey = generated
protected, err := protectData([]byte(privateKey))
if err != nil {
return identity{}, fmt.Errorf("保护设备密钥失败:%w", err)
}
if err := os.WriteFile(keyPath, protected, 0o600); err != nil {
return identity{}, fmt.Errorf("保存设备密钥失败:%w", err)
}
} else {
return identity{}, fmt.Errorf("读取设备密钥失败:%w", readErr)
}
publicKey := privateKey.Public().(ed25519.PublicKey)
return identity{
privateKey: privateKey,
publicKey: publicKey,
deviceID: buildDeviceID(publicKey),
}, nil
}