From 19b4d4895fa8a227d10259ae49be8c1f7fa30c4e Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 18 Mar 2026 16:42:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ticket=20redeem=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?+=20=ED=86=A0=ED=81=B0=20=EC=9D=B4=EC=A4=91=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20+=20=EB=B3=B4=EC=95=88/=ED=92=88=EC=A7=88=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch ticket을 서버에서 redeem하여 새 JWT 획득 (토큰 수명 문제 해결) - 게임에 커맨드라인 -token + 환경변수 A301_TOKEN 이중 전달 - fileHash 빈 문자열 이중 방어 (변조된 게임 실행 차단) - Win32 API 반환값 검증 (RegisterClassEx, CreateWindowEx) - 버전 ldflags 주입 지원 (var version = "dev") Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index 24bdba5..4278de0 100644 --- a/main.go +++ b/main.go @@ -24,10 +24,14 @@ import ( "golang.org/x/sys/windows/registry" ) +// version is set at build time via -ldflags "-X main.version=x.y.z" +var version = "dev" + const ( - protocolName = "a301" - gameExeName = "A301.exe" - serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" + protocolName = "a301" + gameExeName = "A301.exe" + serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" + redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket" ) const maxDownloadSize = 2 << 30 // 2GB @@ -241,6 +245,7 @@ func createUIFont(pointSize int, dpi uint32, bold bool) uintptr { face, _ := windows.UTF16FromString("Segoe UI") copy(lf.lfFaceName[:], face) font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf))) + // font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값) return font } @@ -337,7 +342,10 @@ func downloadWithProgress(downloadURL, destDir string) error { lpszClassName: className, hbrBackground: hBrushBg, } - registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) + atom, _, _ := registerClassExWProc.Call(uintptr(unsafe.Pointer(&wc))) + if atom == 0 { + return fmt.Errorf("윈도우 클래스 등록 실패") + } screenW, _, _ := getSystemMetricsProc.Call(smCxScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) @@ -356,6 +364,9 @@ func downloadWithProgress(downloadURL, destDir string) error { x, y, winW, winH, 0, 0, hInstance, 0, ) + if hwnd == 0 { + return fmt.Errorf("다운로드 창 생성 실패") + } titleFont := createUIFont(13, dpi, true) defer deleteObjectProc.Call(titleFont) @@ -788,6 +799,34 @@ func fetchServerInfo() (*downloadInfo, error) { return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr) } +// redeemTicket exchanges a one-time launch ticket for a fresh JWT access token. +// The ticket has a 30-second TTL on the server and can only be used once. +// 재시도 불필요 — ticket은 일회용이므로 한 번 사용(또는 만료)되면 소멸. +func redeemTicket(ticket string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + body := fmt.Sprintf(`{"ticket":"%s"}`, ticket) + resp, err := client.Post(redeemTicketURL, "application/json", strings.NewReader(body)) + if err != nil { + return "", fmt.Errorf("서버에 연결할 수 없습니다: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("런처 인증에 실패했습니다 (HTTP %d)", resp.StatusCode) + } + + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil { + return "", fmt.Errorf("서버 응답을 처리할 수 없습니다: %w", err) + } + if result.Token == "" { + return "", fmt.Errorf("서버가 토큰을 반환하지 않았습니다") + } + return result.Token, nil +} + func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { @@ -885,13 +924,17 @@ func uninstall() error { // ── Game update check + download ───────────────────────────────────────────── func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { + if serverInfo.FileHash == "" { + return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다") + } + needsDownload := false if _, err := os.Stat(gamePath); os.IsNotExist(err) { needsDownload = true } else if err != nil { return fmt.Errorf("게임 파일 확인 실패: %w", err) - } else if serverInfo.FileHash != "" { + } else { localHash, err := hashFile(gamePath) if err != nil { return fmt.Errorf("파일 검증 실패: %w", err) @@ -1045,13 +1088,19 @@ func handleURI(rawURI string) error { return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme) } - token := parsed.Query().Get("token") - if token == "" { + // 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환 + ticket := parsed.Query().Get("token") + if ticket == "" { return fmt.Errorf("토큰이 없습니다") } + + token, err := redeemTicket(ticket) + if err != nil { + return fmt.Errorf("런처 인증에 실패했습니다: %w", err) + } // JWT는 점(.)으로 구분된 3파트 형식이어야 함 if parts := strings.Split(token, "."); len(parts) != 3 { - return fmt.Errorf("유효하지 않은 토큰 형식입니다") + return fmt.Errorf("서버에서 유효하지 않은 토큰을 받았습니다") } gameDir, err := installDir() @@ -1075,7 +1124,7 @@ func handleURI(rawURI string) error { if _, statErr := os.Stat(gamePath); statErr == nil { ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ) if ret == idYes { - cmd := exec.Command(gamePath) + cmd := exec.Command(gamePath, "-token", token) cmd.Dir = gameDir cmd.Env = append(os.Environ(), "A301_TOKEN="+token) if err := cmd.Start(); err != nil { @@ -1104,7 +1153,7 @@ func handleURI(rawURI string) error { return err } - cmd := exec.Command(gamePath) + cmd := exec.Command(gamePath, "-token", token) cmd.Dir = gameDir cmd.Env = append(os.Environ(), "A301_TOKEN="+token) if err := cmd.Start(); err != nil { @@ -1179,7 +1228,7 @@ func main() { msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo) case arg == "--version" || arg == "version": - msgBox("One of the plans 런처", "버전: 1.0.0", mbOK|mbInfo) + msgBox("One of the plans 런처", fmt.Sprintf("버전: %s", version), mbOK|mbInfo) case strings.HasPrefix(arg, protocolName+"://"): if err := handleURI(arg); err != nil {