feat: ticket redeem 인증 + 토큰 이중 전달 + 보안/품질 개선

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:42:43 +09:00
parent 13b44b04a2
commit 19b4d4895f

65
main.go
View File

@@ -24,10 +24,14 @@ import (
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
) )
// version is set at build time via -ldflags "-X main.version=x.y.z"
var version = "dev"
const ( const (
protocolName = "a301" protocolName = "a301"
gameExeName = "A301.exe" gameExeName = "A301.exe"
serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info" serverInfoURL = "https://a301.api.tolelom.xyz/api/download/info"
redeemTicketURL = "https://a301.api.tolelom.xyz/api/auth/redeem-ticket"
) )
const maxDownloadSize = 2 << 30 // 2GB const maxDownloadSize = 2 << 30 // 2GB
@@ -241,6 +245,7 @@ func createUIFont(pointSize int, dpi uint32, bold bool) uintptr {
face, _ := windows.UTF16FromString("Segoe UI") face, _ := windows.UTF16FromString("Segoe UI")
copy(lf.lfFaceName[:], face) copy(lf.lfFaceName[:], face)
font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf))) font, _, _ := createFontIndirectWProc.Call(uintptr(unsafe.Pointer(&lf)))
// font가 0이면 시스템 기본 폰트가 사용됨 (WM_SETFONT에 0 전달 시 기본값)
return font return font
} }
@@ -337,7 +342,10 @@ func downloadWithProgress(downloadURL, destDir string) error {
lpszClassName: className, lpszClassName: className,
hbrBackground: hBrushBg, 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) screenW, _, _ := getSystemMetricsProc.Call(smCxScreen)
screenH, _, _ := getSystemMetricsProc.Call(smCyScreen) screenH, _, _ := getSystemMetricsProc.Call(smCyScreen)
@@ -356,6 +364,9 @@ func downloadWithProgress(downloadURL, destDir string) error {
x, y, winW, winH, x, y, winW, winH,
0, 0, hInstance, 0, 0, 0, hInstance, 0,
) )
if hwnd == 0 {
return fmt.Errorf("다운로드 창 생성 실패")
}
titleFont := createUIFont(13, dpi, true) titleFont := createUIFont(13, dpi, true)
defer deleteObjectProc.Call(titleFont) defer deleteObjectProc.Call(titleFont)
@@ -788,6 +799,34 @@ func fetchServerInfo() (*downloadInfo, error) {
return nil, fmt.Errorf("서버 연결 실패 (%d회 재시도): %w", maxRetries, lastErr) 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) { func hashFile(path string) (string, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
@@ -885,13 +924,17 @@ func uninstall() error {
// ── Game update check + download ───────────────────────────────────────────── // ── Game update check + download ─────────────────────────────────────────────
func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error { func ensureGame(gameDir, gamePath string, serverInfo *downloadInfo) error {
if serverInfo.FileHash == "" {
return fmt.Errorf("서버에서 유효한 파일 정보를 받지 못했습니다")
}
needsDownload := false needsDownload := false
if _, err := os.Stat(gamePath); os.IsNotExist(err) { if _, err := os.Stat(gamePath); os.IsNotExist(err) {
needsDownload = true needsDownload = true
} else if err != nil { } else if err != nil {
return fmt.Errorf("게임 파일 확인 실패: %w", err) return fmt.Errorf("게임 파일 확인 실패: %w", err)
} else if serverInfo.FileHash != "" { } else {
localHash, err := hashFile(gamePath) localHash, err := hashFile(gamePath)
if err != nil { if err != nil {
return fmt.Errorf("파일 검증 실패: %w", err) return fmt.Errorf("파일 검증 실패: %w", err)
@@ -1045,13 +1088,19 @@ func handleURI(rawURI string) error {
return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme) return fmt.Errorf("지원하지 않는 URI 스킴: %s", parsed.Scheme)
} }
token := parsed.Query().Get("token") // 웹 클라이언트가 발급한 일회용 티켓을 서버에서 JWT로 교환
if token == "" { ticket := parsed.Query().Get("token")
if ticket == "" {
return fmt.Errorf("토큰이 없습니다") return fmt.Errorf("토큰이 없습니다")
} }
token, err := redeemTicket(ticket)
if err != nil {
return fmt.Errorf("런처 인증에 실패했습니다: %w", err)
}
// JWT는 점(.)으로 구분된 3파트 형식이어야 함 // JWT는 점(.)으로 구분된 3파트 형식이어야 함
if parts := strings.Split(token, "."); len(parts) != 3 { if parts := strings.Split(token, "."); len(parts) != 3 {
return fmt.Errorf("유효하지 않은 토큰 형식입니다") return fmt.Errorf("서버에서 유효하지 않은 토큰을 받았습니다")
} }
gameDir, err := installDir() gameDir, err := installDir()
@@ -1075,7 +1124,7 @@ func handleURI(rawURI string) error {
if _, statErr := os.Stat(gamePath); statErr == nil { if _, statErr := os.Stat(gamePath); statErr == nil {
ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ) ret := msgBox("One of the plans", "서버에 연결할 수 없습니다.\n설치된 게임을 실행하시겠습니까?\n(업데이트 확인 불가)", mbYesNo|mbQ)
if ret == idYes { if ret == idYes {
cmd := exec.Command(gamePath) cmd := exec.Command(gamePath, "-token", token)
cmd.Dir = gameDir cmd.Dir = gameDir
cmd.Env = append(os.Environ(), "A301_TOKEN="+token) cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
@@ -1104,7 +1153,7 @@ func handleURI(rawURI string) error {
return err return err
} }
cmd := exec.Command(gamePath) cmd := exec.Command(gamePath, "-token", token)
cmd.Dir = gameDir cmd.Dir = gameDir
cmd.Env = append(os.Environ(), "A301_TOKEN="+token) cmd.Env = append(os.Environ(), "A301_TOKEN="+token)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
@@ -1179,7 +1228,7 @@ func main() {
msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo) msgBox("One of the plans 런처", "a301:// 프로토콜이 제거되었습니다.", mbOK|mbInfo)
case arg == "--version" || arg == "version": 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+"://"): case strings.HasPrefix(arg, protocolName+"://"):
if err := handleURI(arg); err != nil { if err := handleURI(arg); err != nil {