qr.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. package login
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "crypto/rsa"
  7. "crypto/sha256"
  8. "crypto/x509"
  9. "encoding/base64"
  10. "encoding/json"
  11. "errors"
  12. "fmt"
  13. "io"
  14. "log/slog"
  15. stdhttp "net/http"
  16. "strings"
  17. "time"
  18. "github.com/ayn2op/discordo/internal/config"
  19. apphttp "github.com/ayn2op/discordo/internal/http"
  20. "github.com/ayn2op/discordo/internal/ui"
  21. "github.com/ayn2op/tview"
  22. "github.com/diamondburned/arikawa/v3/api"
  23. "github.com/gdamore/tcell/v2"
  24. "github.com/gorilla/websocket"
  25. "github.com/skip2/go-qrcode"
  26. )
  27. const gatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
  28. var remoteAuthLogin = api.EndpointMe + "/remote-auth/login"
  29. type qrLogin struct {
  30. *tview.TextView
  31. app *tview.Application
  32. cfg *config.Config
  33. view *tview.TextView
  34. done func(token string, err error)
  35. conn *websocket.Conn
  36. privKey *rsa.PrivateKey
  37. cancel context.CancelFunc
  38. fingerprint string
  39. }
  40. func newQRLogin(app *tview.Application, cfg *config.Config, done func(token string, err error)) *qrLogin {
  41. view := tview.NewTextView().
  42. SetDynamicColors(true).
  43. SetScrollable(true).
  44. SetWrap(false).
  45. SetTextAlign(tview.AlignmentCenter)
  46. view.Box = ui.ConfigureBox(view.Box, &cfg.Theme)
  47. view.SetTitle("Login with QR")
  48. q := &qrLogin{
  49. app: app,
  50. cfg: cfg,
  51. view: view,
  52. done: done,
  53. }
  54. q.TextView = view
  55. view.SetChangedFunc(func() {
  56. q.app.QueueUpdateDraw(func() {})
  57. })
  58. view.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
  59. if ev.Key() == tcell.KeyEsc {
  60. q.stop()
  61. if q.done != nil {
  62. q.done("", nil)
  63. }
  64. return nil
  65. }
  66. return ev
  67. })
  68. return q
  69. }
  70. func (q *qrLogin) centerText(s string) string {
  71. _, _, _, height := q.view.GetInnerRect()
  72. if height == 0 {
  73. height = 40
  74. }
  75. lines := strings.Count(s, "\n") + 1
  76. padding := (height - lines) / 2
  77. if padding < 0 {
  78. padding = 0
  79. } else if padding < 1 && height > lines {
  80. padding = 1
  81. }
  82. return strings.Repeat("\n", padding) + s
  83. }
  84. func (q *qrLogin) start() {
  85. ctx, cancel := context.WithCancel(context.Background())
  86. q.cancel = cancel
  87. go q.run(ctx)
  88. }
  89. func (q *qrLogin) stop() {
  90. if q.cancel != nil {
  91. q.cancel()
  92. }
  93. if q.conn != nil {
  94. q.conn.Close()
  95. }
  96. }
  97. func (q *qrLogin) writeJSON(data any) error {
  98. return q.conn.WriteJSON(data)
  99. }
  100. type raHello struct {
  101. TimeoutMs int `json:"timeout_ms"`
  102. HeartbeatInterval int `json:"heartbeat_interval"`
  103. }
  104. type raNonceProof struct {
  105. EncryptedNonce string `json:"encrypted_nonce"`
  106. }
  107. type raPendingInit struct {
  108. Fingerprint string `json:"fingerprint"`
  109. }
  110. type raPendingLogin struct {
  111. Ticket string `json:"ticket"`
  112. }
  113. type raPendingTicket struct {
  114. EncryptedUserPayload string `json:"encrypted_user_payload"`
  115. }
  116. func (q *qrLogin) run(ctx context.Context) {
  117. defer q.stop()
  118. setText := func(s string) {
  119. q.app.QueueUpdateDraw(func() {
  120. q.view.SetText(q.centerText(s))
  121. })
  122. }
  123. setText("Preparing QR code...\n\n[::d]Press Esc to cancel[-]")
  124. privKey, err := rsa.GenerateKey(rand.Reader, 2048)
  125. if err != nil {
  126. q.fail(err)
  127. return
  128. }
  129. q.privKey = privKey
  130. pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
  131. if err != nil {
  132. q.fail(err)
  133. return
  134. }
  135. encodedPublicKey := base64.StdEncoding.EncodeToString(pubDER)
  136. headers := stdhttp.Header{}
  137. headers.Set("User-Agent", apphttp.BrowserUserAgent)
  138. headers.Set("Origin", "https://discord.com")
  139. setText("Connecting to Remote Auth Gateway...\n\n[::d]Press Esc to cancel[-]")
  140. dialer := websocket.Dialer{
  141. Proxy: stdhttp.ProxyFromEnvironment,
  142. HandshakeTimeout: 15 * time.Second,
  143. EnableCompression: true,
  144. }
  145. conn, resp, err := dialer.DialContext(ctx, gatewayURL, headers)
  146. if err != nil {
  147. var body []byte
  148. if resp != nil && resp.Body != nil {
  149. body, _ = io.ReadAll(resp.Body)
  150. }
  151. status := ""
  152. if resp != nil {
  153. status = resp.Status
  154. }
  155. q.fail(fmt.Errorf("websocket dial failed: %w, status=%s, body=%s", err, status, string(body)))
  156. return
  157. }
  158. q.conn = conn
  159. readCh := make(chan []byte, 1)
  160. readErr := make(chan error, 1)
  161. go func() {
  162. for {
  163. _, data, err := conn.ReadMessage()
  164. if err != nil {
  165. readErr <- err
  166. return
  167. }
  168. readCh <- data
  169. }
  170. }()
  171. var heartbeatTicker *time.Ticker
  172. defer func() {
  173. if heartbeatTicker != nil {
  174. heartbeatTicker.Stop()
  175. }
  176. }()
  177. for {
  178. select {
  179. case <-ctx.Done():
  180. return
  181. case err := <-readErr:
  182. if mapped := mapWSCloseError(err); mapped == nil {
  183. return
  184. } else {
  185. q.fail(mapped)
  186. }
  187. return
  188. case data := <-readCh:
  189. var opOnly struct {
  190. Op string `json:"op"`
  191. }
  192. if err := json.Unmarshal(data, &opOnly); err != nil {
  193. setText("[red]Bad JSON:[-] " + err.Error())
  194. q.fail(err)
  195. return
  196. }
  197. switch opOnly.Op {
  198. case "hello":
  199. var h raHello
  200. if err := json.Unmarshal(data, &h); err != nil {
  201. setText("[red]Hello decode failed:[-] " + err.Error())
  202. q.fail(err)
  203. return
  204. }
  205. if h.HeartbeatInterval > 0 {
  206. heartbeatTicker = time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
  207. go func() {
  208. for {
  209. select {
  210. case <-ctx.Done():
  211. return
  212. case <-heartbeatTicker.C:
  213. _ = q.writeJSON(map[string]any{"op": "heartbeat"})
  214. }
  215. }
  216. }()
  217. }
  218. setText("Connected. Handshaking...\n\n[::d]Press Esc to cancel[-]")
  219. if err := q.writeJSON(map[string]any{
  220. "op": "init",
  221. "encoded_public_key": encodedPublicKey,
  222. }); err != nil {
  223. setText("[red]Init send failed:[-] " + err.Error())
  224. q.fail(err)
  225. return
  226. }
  227. case "nonce_proof":
  228. var n raNonceProof
  229. if err := json.Unmarshal(data, &n); err != nil {
  230. setText("[red]Nonce decode failed:[-] " + err.Error())
  231. q.fail(err)
  232. return
  233. }
  234. enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce)
  235. if err != nil {
  236. setText("[red]Nonce b64 decode failed:[-] " + err.Error())
  237. q.fail(err)
  238. return
  239. }
  240. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil)
  241. if err != nil {
  242. setText("[red]Nonce decrypt failed:[-] " + err.Error())
  243. q.fail(err)
  244. return
  245. }
  246. nonce := base64.RawURLEncoding.EncodeToString(pt)
  247. if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil {
  248. setText("[red]Nonce send failed:[-] " + err.Error())
  249. q.fail(err)
  250. return
  251. }
  252. case "pending_remote_init":
  253. var p raPendingInit
  254. if err := json.Unmarshal(data, &p); err != nil {
  255. setText("[red]Init decode failed:[-] " + err.Error())
  256. q.fail(err)
  257. return
  258. }
  259. q.fingerprint = p.Fingerprint
  260. content := "https://discord.com/ra/" + p.Fingerprint
  261. ascii, err := renderQR(content)
  262. if err != nil {
  263. setText("[red]QR render failed:[-] " + err.Error())
  264. q.fail(err)
  265. return
  266. }
  267. setText(ascii + "\n\n[::b]Scan with Discord mobile app[::-]\n\n[::d]Press Esc to cancel[-]")
  268. case "heartbeat_ack":
  269. case "pending_ticket":
  270. var t raPendingTicket
  271. if err := json.Unmarshal(data, &t); err != nil {
  272. setText("[red]Ticket decode failed:[-] " + err.Error())
  273. q.fail(err)
  274. return
  275. }
  276. payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload)
  277. if err != nil {
  278. setText("[red]Ticket payload b64 failed:[-] " + err.Error())
  279. q.fail(err)
  280. return
  281. }
  282. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil)
  283. if err != nil {
  284. setText("[red]Ticket payload decrypt failed:[-] " + err.Error())
  285. q.fail(err)
  286. return
  287. }
  288. parts := strings.SplitN(string(pt), ":", 4)
  289. var discriminator, username string
  290. if len(parts) == 4 {
  291. discriminator = parts[1]
  292. username = parts[3]
  293. }
  294. if discriminator == "" && username == "" {
  295. setText("Scan received.\n\nWaiting for approval on mobile...\n\n[::d]Press Esc to cancel[-]")
  296. } else {
  297. setText("Logging in as [::b]" + username + "[#]" + discriminator + "[::-]\n\nConfirm on mobile...\n\n[::d]Press Esc to cancel[-]")
  298. }
  299. case "pending_login":
  300. var p raPendingLogin
  301. if err := json.Unmarshal(data, &p); err != nil {
  302. setText("[red]Login decode failed:[-] " + err.Error())
  303. q.fail(err)
  304. return
  305. }
  306. setText("Authenticating...\n\n[::d]Please wait[-]")
  307. token, err := exchangeTicket(ctx, p.Ticket, q.fingerprint, q.privKey)
  308. if err != nil {
  309. setText("[red]Ticket exchange failed:[-] " + err.Error())
  310. q.fail(err)
  311. return
  312. }
  313. q.success(token)
  314. return
  315. case "cancel":
  316. setText("Login canceled on mobile")
  317. if q.done != nil {
  318. q.done("", nil)
  319. }
  320. return
  321. default:
  322. }
  323. }
  324. }
  325. }
  326. func renderQR(content string) (string, error) {
  327. code, err := qrcode.New(content, qrcode.Low)
  328. if err != nil {
  329. return "", err
  330. }
  331. bm := code.Bitmap()
  332. var b strings.Builder
  333. for y := 0; y < len(bm); y += 2 {
  334. for x := range bm[y] {
  335. top := bm[y][x]
  336. bottom := false
  337. if y+1 < len(bm) {
  338. bottom = bm[y+1][x]
  339. }
  340. if top && bottom {
  341. b.WriteString("█")
  342. } else if top && !bottom {
  343. b.WriteString("▀")
  344. } else if !top && bottom {
  345. b.WriteString("▄")
  346. } else {
  347. b.WriteString(" ")
  348. }
  349. }
  350. b.WriteByte('\n')
  351. }
  352. return b.String(), nil
  353. }
  354. func exchangeTicket(ctx context.Context, ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) {
  355. if ticket == "" {
  356. return "", errors.New("empty ticket")
  357. }
  358. body := map[string]string{"ticket": ticket}
  359. raw, err := json.Marshal(body)
  360. if err != nil {
  361. return "", err
  362. }
  363. req, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodPost, remoteAuthLogin, bytes.NewReader(raw))
  364. if err != nil {
  365. return "", err
  366. }
  367. req.Header = apphttp.Headers()
  368. req.Header.Set("Content-Type", "application/json")
  369. req.Header.Set("User-Agent", apphttp.BrowserUserAgent)
  370. if fingerprint != "" {
  371. req.Header.Set("X-Fingerprint", fingerprint)
  372. req.Header.Set("Referer", "https://discord.com/ra/"+fingerprint)
  373. }
  374. client := &stdhttp.Client{Transport: apphttp.NewTransport(), Timeout: 20 * time.Second}
  375. resp, err := client.Do(req)
  376. if err != nil {
  377. return "", err
  378. }
  379. defer resp.Body.Close()
  380. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  381. b, _ := io.ReadAll(resp.Body)
  382. return "", fmt.Errorf("remote-auth login failed: %s: %s", resp.Status, string(b))
  383. }
  384. decoder := json.NewDecoder(resp.Body)
  385. var out struct {
  386. EncryptedToken string `json:"encrypted_token"`
  387. }
  388. if err := decoder.Decode(&out); err != nil {
  389. return "", err
  390. }
  391. if out.EncryptedToken == "" {
  392. return "", fmt.Errorf("no encrypted_token in response")
  393. }
  394. enc, err := base64.StdEncoding.DecodeString(out.EncryptedToken)
  395. if err != nil {
  396. return "", err
  397. }
  398. pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, enc, nil)
  399. if err != nil {
  400. return "", err
  401. }
  402. return string(pt), nil
  403. }
  404. func (q *qrLogin) success(token string) {
  405. if q.done != nil {
  406. q.done(token, nil)
  407. }
  408. }
  409. func (q *qrLogin) fail(err error) {
  410. slog.Error("qr login failed", "err", err)
  411. if q.done != nil {
  412. q.done("", err)
  413. }
  414. }
  415. func mapWSCloseError(err error) error {
  416. var cerr *websocket.CloseError
  417. if errors.As(err, &cerr) {
  418. switch cerr.Code {
  419. case 1000:
  420. return errors.New("session closed")
  421. case 4000:
  422. return errors.New("remote auth: invalid version")
  423. case 4001:
  424. return errors.New("remote auth: decode error")
  425. case 4002:
  426. return errors.New("remote auth: handshake failure")
  427. case 4003:
  428. return errors.New("remote auth: session timed out")
  429. }
  430. }
  431. return err
  432. }