diff --git a/cmd/application.go b/cmd/application.go index b2a171a..7cd5257 100644 --- a/cmd/application.go +++ b/cmd/application.go @@ -51,7 +51,7 @@ func newApplication(cfg *config.Config) *application { func (a *application) run(token string) error { if token == "" { - loginForm := login.NewForm(a.cfg, func(token string) { + loginForm := login.NewForm(a.Application, a.cfg, func(token string) { if err := a.run(token); err != nil { slog.Error("failed to run application", "err", err) } diff --git a/go.mod b/go.mod index a09111c..79cda8f 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,12 @@ require ( github.com/gdamore/tcell/v2 v2.9.0 github.com/gen2brain/beeep v0.11.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/klauspost/compress v1.18.0 github.com/lmittmann/tint v1.1.2 github.com/ncruces/zenity v0.10.14 github.com/sahilm/fuzzy v0.1.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/yuin/goldmark v1.7.13 github.com/zalando/go-keyring v0.2.6 @@ -34,7 +36,6 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/schema v1.4.1 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/josephspurrier/goversioninfo v1.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 0d59ac9..57fe47c 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,8 @@ github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNW github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/login/form.go b/internal/login/form.go index b92d1ff..632d321 100644 --- a/internal/login/form.go +++ b/internal/login/form.go @@ -17,20 +17,23 @@ import ( const ( formPageName = "form" errorPageName = "error" + qrPageName = "qr" ) type DoneFn = func(token string) type Form struct { *tview.Pages + app *tview.Application cfg *config.Config form *tview.Form done DoneFn } -func NewForm(cfg *config.Config, done DoneFn) *Form { +func NewForm(app *tview.Application, cfg *config.Config, done DoneFn) *Form { f := &Form{ Pages: tview.NewPages(), + app: app, cfg: cfg, form: tview.NewForm(), done: done, @@ -40,7 +43,8 @@ func NewForm(cfg *config.Config, done DoneFn) *Form { AddInputField("Email", "", 0, nil, nil). AddPasswordField("Password", "", 0, 0, nil). AddPasswordField("Code (optional)", "", 0, 0, nil). - AddButton("Login", f.login) + AddButton("Login", f.login). + AddButton("Login with QR", f.loginWithQR) f.AddAndSwitchToPage(formPageName, f.form, true) return f } @@ -114,3 +118,26 @@ func (f *Form) onError(err error) { AddAndSwitchToPage(errorPageName, ui.Centered(modal, 0, 0), true). ShowPage(formPageName) } + +func (f *Form) loginWithQR() { + qr := newQRLogin(f.app, f.cfg, func(token string, err error) { + if err != nil { + f.onError(err) + return + } + + if token == "" { + f.RemovePage(qrPageName).SwitchToPage(formPageName) + return + } + + go keyring.Set(consts.Name, "token", token) + f.RemovePage(qrPageName) + if f.done != nil { + f.done(token) + } + }) + + f.AddAndSwitchToPage(qrPageName, qr, true) + qr.start() +} diff --git a/internal/login/qr.go b/internal/login/qr.go new file mode 100644 index 0000000..79770a0 --- /dev/null +++ b/internal/login/qr.go @@ -0,0 +1,474 @@ +package login + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + stdhttp "net/http" + "strings" + "time" + + "github.com/ayn2op/discordo/internal/config" + apphttp "github.com/ayn2op/discordo/internal/http" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/tview" + "github.com/diamondburned/arikawa/v3/api" + "github.com/gdamore/tcell/v2" + "github.com/gorilla/websocket" + "github.com/skip2/go-qrcode" +) + +const gatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2" + +var remoteAuthLogin = api.EndpointMe + "/remote-auth/login" + +type qrLogin struct { + *tview.TextView + app *tview.Application + cfg *config.Config + view *tview.TextView + done func(token string, err error) + conn *websocket.Conn + privKey *rsa.PrivateKey + cancel context.CancelFunc + fingerprint string +} + +func newQRLogin(app *tview.Application, cfg *config.Config, done func(token string, err error)) *qrLogin { + view := tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetWrap(false). + SetTextAlign(tview.AlignmentCenter) + view.Box = ui.ConfigureBox(view.Box, &cfg.Theme) + view.SetTitle("Login with QR") + + q := &qrLogin{ + app: app, + cfg: cfg, + view: view, + done: done, + } + q.TextView = view + + view.SetChangedFunc(func() { + q.app.QueueUpdateDraw(func() {}) + }) + + view.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + if ev.Key() == tcell.KeyEsc { + q.stop() + if q.done != nil { + q.done("", nil) + } + return nil + } + return ev + }) + + return q +} + + +func (q *qrLogin) centerText(s string) string { + _, _, _, height := q.view.GetInnerRect() + if height == 0 { + height = 40 + } + lines := strings.Count(s, "\n") + 1 + padding := (height - lines) / 2 + if padding < 0 { + padding = 0 + } else if padding < 1 && height > lines { + padding = 1 + } + return strings.Repeat("\n", padding) + s +} + +func (q *qrLogin) start() { + ctx, cancel := context.WithCancel(context.Background()) + q.cancel = cancel + go q.run(ctx) +} + +func (q *qrLogin) stop() { + if q.cancel != nil { + q.cancel() + } + if q.conn != nil { + q.conn.Close() + } +} + +func (q *qrLogin) writeJSON(data any) error { + return q.conn.WriteJSON(data) +} + +type raHello struct { + TimeoutMs int `json:"timeout_ms"` + HeartbeatInterval int `json:"heartbeat_interval"` +} + +type raNonceProof struct { + EncryptedNonce string `json:"encrypted_nonce"` +} + +type raPendingInit struct { + Fingerprint string `json:"fingerprint"` +} + +type raPendingLogin struct { + Ticket string `json:"ticket"` +} + +type raPendingTicket struct { + EncryptedUserPayload string `json:"encrypted_user_payload"` +} + +func (q *qrLogin) run(ctx context.Context) { + defer q.stop() + + setText := func(s string) { + q.app.QueueUpdateDraw(func() { + q.view.SetText(q.centerText(s)) + }) + } + + setText("Preparing QR code...\n\n[::d]Press Esc to cancel[-]") + + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + q.fail(err) + return + } + q.privKey = privKey + + pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + q.fail(err) + return + } + encodedPublicKey := base64.StdEncoding.EncodeToString(pubDER) + + headers := stdhttp.Header{} + headers.Set("User-Agent", apphttp.BrowserUserAgent) + headers.Set("Origin", "https://discord.com") + + setText("Connecting to Remote Auth Gateway...\n\n[::d]Press Esc to cancel[-]") + + dialer := websocket.Dialer{ + Proxy: stdhttp.ProxyFromEnvironment, + HandshakeTimeout: 15 * time.Second, + EnableCompression: true, + } + + conn, resp, err := dialer.DialContext(ctx, gatewayURL, headers) + if err != nil { + var body []byte + if resp != nil && resp.Body != nil { + body, _ = io.ReadAll(resp.Body) + } + status := "" + if resp != nil { + status = resp.Status + } + q.fail(fmt.Errorf("websocket dial failed: %w, status=%s, body=%s", err, status, string(body))) + return + } + q.conn = conn + + readCh := make(chan []byte, 1) + readErr := make(chan error, 1) + go func() { + for { + _, data, err := conn.ReadMessage() + if err != nil { + readErr <- err + return + } + readCh <- data + } + }() + + var heartbeatTicker *time.Ticker + defer func() { + if heartbeatTicker != nil { + heartbeatTicker.Stop() + } + }() + + for { + select { + case <-ctx.Done(): + return + case err := <-readErr: + if mapped := mapWSCloseError(err); mapped == nil { + return + } else { + q.fail(mapped) + } + return + case data := <-readCh: + var opOnly struct { + Op string `json:"op"` + } + if err := json.Unmarshal(data, &opOnly); err != nil { + setText("[red]Bad JSON:[-] " + err.Error()) + q.fail(err) + return + } + + switch opOnly.Op { + case "hello": + var h raHello + if err := json.Unmarshal(data, &h); err != nil { + setText("[red]Hello decode failed:[-] " + err.Error()) + q.fail(err) + return + } + if h.HeartbeatInterval > 0 { + heartbeatTicker = time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-heartbeatTicker.C: + _ = q.writeJSON(map[string]any{"op": "heartbeat"}) + } + } + }() + } + setText("Connected. Handshaking...\n\n[::d]Press Esc to cancel[-]") + if err := q.writeJSON(map[string]any{ + "op": "init", + "encoded_public_key": encodedPublicKey, + }); err != nil { + setText("[red]Init send failed:[-] " + err.Error()) + q.fail(err) + return + } + case "nonce_proof": + var n raNonceProof + if err := json.Unmarshal(data, &n); err != nil { + setText("[red]Nonce decode failed:[-] " + err.Error()) + q.fail(err) + return + } + enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce) + if err != nil { + setText("[red]Nonce b64 decode failed:[-] " + err.Error()) + q.fail(err) + return + } + pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil) + if err != nil { + setText("[red]Nonce decrypt failed:[-] " + err.Error()) + q.fail(err) + return + } + nonce := base64.RawURLEncoding.EncodeToString(pt) + if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil { + setText("[red]Nonce send failed:[-] " + err.Error()) + q.fail(err) + return + } + case "pending_remote_init": + var p raPendingInit + if err := json.Unmarshal(data, &p); err != nil { + setText("[red]Init decode failed:[-] " + err.Error()) + q.fail(err) + return + } + q.fingerprint = p.Fingerprint + content := "https://discord.com/ra/" + p.Fingerprint + ascii, err := renderQR(content) + if err != nil { + setText("[red]QR render failed:[-] " + err.Error()) + q.fail(err) + return + } + setText(ascii + "\n\n[::b]Scan with Discord mobile app[::-]\n\n[::d]Press Esc to cancel[-]") + case "heartbeat_ack": + case "pending_ticket": + var t raPendingTicket + if err := json.Unmarshal(data, &t); err != nil { + setText("[red]Ticket decode failed:[-] " + err.Error()) + q.fail(err) + return + } + payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload) + if err != nil { + setText("[red]Ticket payload b64 failed:[-] " + err.Error()) + q.fail(err) + return + } + pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil) + if err != nil { + setText("[red]Ticket payload decrypt failed:[-] " + err.Error()) + q.fail(err) + return + } + parts := strings.SplitN(string(pt), ":", 4) + var discriminator, username string + if len(parts) == 4 { + discriminator = parts[1] + username = parts[3] + } + if discriminator == "" && username == "" { + setText("Scan received.\n\nWaiting for approval on mobile...\n\n[::d]Press Esc to cancel[-]") + } else { + setText("Logging in as [::b]" + username + "[#]" + discriminator + "[::-]\n\nConfirm on mobile...\n\n[::d]Press Esc to cancel[-]") + } + case "pending_login": + var p raPendingLogin + if err := json.Unmarshal(data, &p); err != nil { + setText("[red]Login decode failed:[-] " + err.Error()) + q.fail(err) + return + } + setText("Authenticating...\n\n[::d]Please wait[-]") + token, err := exchangeTicket(ctx, p.Ticket, q.fingerprint, q.privKey) + if err != nil { + setText("[red]Ticket exchange failed:[-] " + err.Error()) + q.fail(err) + return + } + q.success(token) + return + case "cancel": + setText("Login canceled on mobile") + if q.done != nil { + q.done("", nil) + } + return + default: + } + } + } +} + +func renderQR(content string) (string, error) { + code, err := qrcode.New(content, qrcode.Low) + if err != nil { + return "", err + } + bm := code.Bitmap() + var b strings.Builder + for y := 0; y < len(bm); y += 2 { + for x := range bm[y] { + top := bm[y][x] + bottom := false + if y+1 < len(bm) { + bottom = bm[y+1][x] + } + if top && bottom { + b.WriteString("█") + } else if top && !bottom { + b.WriteString("▀") + } else if !top && bottom { + b.WriteString("▄") + } else { + b.WriteString(" ") + } + } + b.WriteByte('\n') + } + return b.String(), nil +} + +func exchangeTicket(ctx context.Context, ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) { + if ticket == "" { + return "", errors.New("empty ticket") + } + body := map[string]string{"ticket": ticket} + raw, err := json.Marshal(body) + if err != nil { + return "", err + } + req, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodPost, remoteAuthLogin, bytes.NewReader(raw)) + if err != nil { + return "", err + } + + req.Header = apphttp.Headers() + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", apphttp.BrowserUserAgent) + if fingerprint != "" { + req.Header.Set("X-Fingerprint", fingerprint) + req.Header.Set("Referer", "https://discord.com/ra/"+fingerprint) + } + + client := &stdhttp.Client{Transport: apphttp.NewTransport(), Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("remote-auth login failed: %s: %s", resp.Status, string(b)) + } + + decoder := json.NewDecoder(resp.Body) + + var out struct { + EncryptedToken string `json:"encrypted_token"` + } + if err := decoder.Decode(&out); err != nil { + return "", err + } + if out.EncryptedToken == "" { + return "", fmt.Errorf("no encrypted_token in response") + } + enc, err := base64.StdEncoding.DecodeString(out.EncryptedToken) + if err != nil { + return "", err + } + pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, enc, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +func (q *qrLogin) success(token string) { + if q.done != nil { + q.done(token, nil) + } +} + +func (q *qrLogin) fail(err error) { + slog.Error("qr login failed", "err", err) + if q.done != nil { + q.done("", err) + } +} + +func mapWSCloseError(err error) error { + var cerr *websocket.CloseError + if errors.As(err, &cerr) { + switch cerr.Code { + case 1000: + return errors.New("session closed") + case 4000: + return errors.New("remote auth: invalid version") + case 4001: + return errors.New("remote auth: decode error") + case 4002: + return errors.New("remote auth: handshake failure") + case 4003: + return errors.New("remote auth: session timed out") + } + } + return err +}